<script> tag: an
iframe-isolated React widget streaming Gemini replies over SSE, designed and built
solo in one week.A sportello is the counter window at an Italian public office — the desk where citizens queue to ask which procedure applies to them. This one embeds in any webpage.

One unedited take against the running stack: the loader’s launcher on a plain host page, the panel opening on a fresh session, a question typed and sent, and the reply streaming into the bubble token by token.

Embedding it is one line on the host page. This is the entire integration:
<script src="https://sportello.example.com/loader.js" data-bot-id="demo-bot" async></script>No build step, no CSS or JS conflicts, nothing else to paste. The 3.4 KB loader injects the launcher button; the chat UI, the AI, and all secrets stay on the widget’s own origin.
Highlights#
- Designed and built the whole system solo in one week: a self-hosted AI support chat any website adds with one
<script>tag. - Architected three deliberately decoupled artifacts — a 3.4 KB vanilla-JS loader, an iframe-isolated React widget, and a FastAPI + LangChain agent backend — joined only by versioned postMessage and API contracts, shipped as a six-service
docker compose up. - Implemented rate limiting as an atomic Redis Lua sliding window, exact across 4 gunicorn workers: load tests admitted exactly 20 of 40 concurrent requests and returned honest
Retry-Aftervalues on the rest. - Guaranteed the stored transcript always matches what the user saw by shielding the SSE teardown from cancellation; killing the API mid-stream still persisted the partial reply.
- Hardened the admin console with a timing-balanced, enumeration-resistant login in a JWT trust domain fully disjoint from the widget’s, covered by pytest down to a bcrypt cost-parity test.
How it works#
flowchart TD
subgraph host["Host page (any site)"]
L["loader.js
3.4 KB IIFE, launcher"]
IF["iframe /v1/
React widget"]
L -. "postMessage
(origin-checked)" .- IF
end
IF -->|"POST /session/init → JWT
POST /chat → SSE stream"| N["nginx :8090
statics + proxy,
buffering off"]
N --> A["FastAPI × 4 workers
LangChain agent"]
A --> G["Gemini 2.5 Flash"]
A --> T["procedure-search API
(tool call)"]
A --> PG[("Postgres
transcript + agent checkpoints")]
A --> R[("Redis
rate-limit counters")]
Three artifacts stay decoupled on purpose: the vanilla-JS loader talks to the widget only via a validated postMessage contract, and the widget talks to the backend only via a versioned API contract. Adding a backend tool never touches the wire format or the widget. Allowing a new site to embed is an env edit, not a rebuild: FRAME_ANCESTORS (who may iframe the widget) and ALLOWED_ORIGINS (who may call the API) are rendered into nginx and the backend at container start.
The isolation model: host page, widget origin, and backend are three trust domains joined only by two versioned contracts — origin-checked postMessage and the JWT/SSE API — and the red edge is the guarantee that no secret ever ships to the browser.
Portfolio Case Study#
Problem. The company needed an AI assistant that embeds into an existing Drupal site serving Italian public-administration procedures, where citizens and businesses ask which procedure applies to them. Three constraints shaped the whole design: the embed must never break the host page, no secrets can reach the browser, and abuse has to be bounded because every admitted chat message is real LLM spend. The PRD’s guiding insight: you cannot cryptographically prove a request came from “your” widget, so instead of chasing that, restrict which sites can embed, keep secrets server-side, and make abuse cheap to absorb.
Approach. The decision I’d defend hardest is the walking skeleton with hard phase gates. v0.1 streamed a fake canned reply through the complete pipeline (loader, iframe, nginx, SSE) before any AI existed, because streaming through nginx was the riskiest plumbing in the design; one wrong proxy setting turns a token stream into a single buffered burst. Sessions and persistence (v0.2) had to pass every exit criterion before the model landed in v0.3, which meant auth bugs and LLM behavior were never debugged at the same time. Each phase’s exit criteria are recorded in the roadmap with dates and results.
The PRD was originally drafted around Anthropic, provider middleware and all. In v0.3 I switched to Gemini: the implicit caching, the price-to-performance, and the speed of 2.5 Flash were too good not to integrate. The switch cost little because everything provider-shaped (checkpointer, trim middleware, SSE grammar) was neutral by design, and the byte-stable system prompt rule carried over directly, since Gemini’s implicit caching prefix-matches on literal bytes.
Conversation state is stored twice, deliberately. A plain threads/messages schema is what the UI restores and the admin console browses; LangGraph’s Postgres checkpointer holds the agent’s memory. A wrap_model_call middleware trims only what each model call sees, so the persisted history stays complete while the model’s view stays affordable. Auth follows the same “what is this actually for” reasoning: the widget JWT is an anti-abuse ticket, not a user credential, so there are no refresh tokens and no revocation list. Re-init is the refresh, and it’s free because there is no login.
What was hard. The best bug in the project is the rate limiter that bypassed itself. Under a 60-way concurrent burst, redis-py’s default connection pool raised MaxConnectionsError the moment the burst exceeded its size. The limiter’s fail-open path read that as “Redis is down” and let requests through, so the burst leaked past the very limit it had triggered: 33 admissions where 30 were configured. The fix was a BlockingConnectionPool, turning a hard error into sub-millisecond backpressure; the rerun admitted exactly 30 of 60. It’s the reason the roadmap has a working agreement called “test the silent failures deliberately.”
SSE teardown was the other fight. A streaming response commits its 200 the moment the generator starts, so everything that can still change the status code (JWT check, quota, length cap, writing the user’s message) runs before the stream opens. The assistant row is written in a finally block wrapped in anyio.CancelScope(shield=True), so the partial reply survives the client disconnect that cancels the generator. Even the deploy path had to cooperate: Docker Compose’s default 10-second stop would have SIGKILLed workers before gunicorn’s 120-second graceful drain mattered, silently defeating the whole mechanism. With stop_grace_period: 130s, restarting the stack mid-stream let the reply finish cleanly and persisted the full 1,275-character row.
Twice the framework’s documentation and its source disagreed, and the source won: LangChain’s documented sync wrap_model_call breaks under astream() (the middleware has to be async), and langchain-google-genai advertises a GEMINI_API_KEY env fallback it never consults — both verified against the installed source and worked around explicitly.
Outcome. A complete, working stack in one week of solo work: 18 commits from first scaffold to the read-only admin console, shipped as a six-service docker compose up. It is demo-complete and not yet deployed, so the honest numbers are verification numbers, each dated in the roadmap: token streaming proven incremental through nginx (a ~250-word reply arrived as 8 events over 1.17s), rate limits exact under 4 workers (40 concurrent → exactly 20 admitted; 60 → exactly 30), JWT expiry mid-conversation recovers transparently on the same thread, and the trim middleware was probed directly (the model saw 3 messages while checkpointed state held 9). The built JS bundles were grepped to confirm no secret ever ships to the browser. The admin surface carries a pytest suite covering auth, parameter validation, and config fail-closed behavior, including a structural test that the dummy bcrypt hash used for unknown usernames matches the real hash’s cost factor, which makes the login’s timing balance a tested property rather than a hope.
Non-goals. No user accounts and no cross-device sync; possession of an unguessable server-minted thread ID is ownership. One hardcoded bot: multi-tenancy exists only as a seam (get_bot_config()), not an implementation. Safari private-mode users silently get a fresh conversation each visit, an accepted trade of the iframe isolation model. The admin console is read-only by design.
Hindsight. The tool the bot actively uses is hardcoded in the codebase, and so is the system prompt, which means every capability change is a redeploy. Doing it again, I’d add more control over the chatbot’s capabilities rather than just watching them: let an admin edit the prompt and plug in tools at runtime, ideally through an MCP adapter surface, so a new capability is coded in an MCP server and the AI calls it without touching this codebase.
Screenshots#





Stack#
Python 3.12, FastAPI, LangChain 1.x + LangGraph (Postgres checkpointer), Gemini 2.5 Flash, PostgreSQL 17, Redis, React 19, TypeScript, Tailwind 4, Vite, esbuild, nginx, Docker Compose, pytest, LangSmith tracing.
