- Who this is for: Product engineers shipping embedded chat on a site you own (booking, CRM, bilingual UX) — not a FAQ widget on a landing.
- Problem it solves: SaaS chat owns orchestration; your stack cannot share session state, controlled webhooks, or Google Calendar without leaking integration endpoints to the browser.
- What changes if you apply this: Astro /api/* as the only trust boundary (0 webhook URLs in the client bundle); n8n workflow changes without a frontend redeploy; dual intent layers (user regex + bot keywords) → fewer spurious calendar API calls than prompt-only booking.
You paste a script tag. The widget loads. The conversation starts. Booking, if it exists at all, happens inside someone else's dashboard. Your site becomes a host for a black box that does not share state with your APIs, your calendar, or your content model.
That trade-off is acceptable for a landing page with a FAQ. It is not acceptable when the chatbot is supposed to qualify leads, explain services, and book calls on Google Calendar -- on a site you fully own, in Spanish and English, with a brand voice you control.
For aurin.mx (opens in new tab) I needed a conversational agent embedded in the product surface, not bolted on as SaaS chrome. Production today is three layers: Astro SSR on the edge, TypeScript on the client, and self-hosted n8n on a VPS (Docker via Dockploy/Coolify) — separated so each layer can change without collapsing the others. On that site, chat and calendar calls time out at 30s with retry (no silent hangs), and session history is capped at 50 messages per sess_* id.
The patterns below match what vendors and frameworks document: n8n production webhooks (opens in new tab), Astro server endpoints (opens in new tab) for hiding secrets, and the Model Context Protocol (opens in new tab) idea of separating tools the client may call from data the server owns. Our code illustrates one implementation; the references at the end are what I cite when justifying the architecture to clients.
First, why embedded chatbots break the stack
> In plain terms: Off-the-shelf chat tools install fast, but your team does not own bookings, calendars, or customer data when someone wants a real meeting. Intercom, Tidio, and similar tools optimize for speed of installation, not integration depth. You get a conversation UI and an admin panel. You do not get:
- A webhook URL you fully control, with secrets that never ship to the browser
- Side effects on your APIs (availability, booking, confirmation emails) driven by conversation state
- Two conversation modes (short service search vs full dialog) enforced at the product layer, not left to prompt luck
- Session history bounded and persisted in a way that matches your SSR model
The moment booking enters the picture, the chatbot stops being "messaging" and becomes distributed application logic. Someone has to own session IDs, call Google Calendar (opens in new tab), send transactional email, and decide when the user is in confirm_time vs request_details. SaaS widgets can call external APIs, but the orchestration model is still theirs -- not yours. That is the same class of problem as putting a third-party script on a page without a backend-for-frontend (opens in new tab) layer: the browser sees integration endpoints you do not control.
So the goal was not "add AI to the site." It was: build an agent that lives inside the same architecture as the rest of aurin.mx (Payload CMS for content, Vercel for the Astro app, VPS for automation).
The three-layer model
> In plain terms: Think browser, middle server, and automation brain — each with a clear job so one team can fix chat copy without breaking payments or secrets.
Layer 1 handles UX state: sessions, retries, and calendar side effects triggered by bot wording.
Layer 2 is the trust boundary: credentials, mode injection, and calendar mutations never exposed to the client bundle.
Layer 3 is the reasoning engine: long context, tool-style steps, and workflow changes without redeploying the frontend.
The frontend never calls n8n directly. Every message goes to /api/chat, which forwards to the webhook configured in N8N_WEBHOOK_URL. n8n's own docs distinguish test vs production webhook URLs (opens in new tab) and require the workflow to be Active before the production URL answers — which is exactly the 404 class we handle in chat.ts.
Repository map
> In plain terms: A table of which files do what — useful if you are technical; skip if you only care about outcomes. Everything below is in AurinWebsite (opens in new tab) unless noted. The split with aurin-cms (opens in new tab) is intentional: editorial content vs runtime agent.
| Path | Layer | What it documents |
|---|---|---|
src/pages/api/chat.ts | 2 | Validation, search-mode prefix, 30s timeout, n8n forward, 404 when workflow inactive |
src/lib/chatbot/sessionManager.ts | 1 | sess_* IDs, sessionStorage, 50-message cap, 1h rollover, SSR fallback |
src/lib/chatbot/apiClient.ts | 1 | /api/chat client, 30s abort, retry with backoff |
src/lib/chatbot/calendarIntentHandler.ts | 1 | Bot-output keywords → calendar API calls |
src/lib/calendar/intentDetector.ts | 1 | User-message regex (PATTERNS, confidence) → select_time / provide_details |
src/lib/calendar/googleCalendar.ts | 2 | Google Calendar API (server-only credentials) |
src/lib/calendar/types.ts | 2 | PendingBooking, CalendarMetadata, shared shapes |
src/pages/api/calendar/availability.ts | 2 | Slots query |
src/pages/api/calendar/book.ts | 2 | Create event |
src/pages/api/calendar/confirm.ts | 2 | Confirm flow |
src/pages/api/calendar/select-time.ts | 2 | User picks slot |
src/pages/api/calendar/send-confirmation.ts | 2 | Resend email after n8n creates event |
src/lib/payload.ts | — | CMS fetch for Astro pages (not per chat message) |
Docs/API_Calendar.md | 2–3 | End-to-end booking arc, confirmation tokens (HMAC + TTL), n8n webhook names |
src/i18n/translations.ts | 1 | Widget copy per locale (chatbot, chatbotSearch) |
Server env vars the proxy depends on (never exposed to the browser):
| Variable | Role |
|---|---|
N8N_WEBHOOK_URL | Production chat webhook (overrides dev default in chat.ts) |
| Google Calendar + Resend secrets | Used only under src/pages/api/calendar/* and mailing helpers |
The long-form calendar spec in Docs/API_Calendar.md matches what we implemented — including separate n8n paths such as confirm-appointment so booking side effects do not share the main chat graph:
When debugging production, we read that doc first, then chat.ts logs (Sending to n8n webhook), then n8n execution history — not the widget bundle.
Layer 2 — SSR proxy as security and product logic
> In plain terms: The website’s middle layer hides passwords and rules — like a receptionist who decides what the AI is allowed to do before it answers. The proxy is not a thin pass-through. It validates message and sessionId, applies a 30s AbortController timeout, and implements search mode vs full mode before the payload reaches n8n:
That is deliberate product design. The marketing search bar and the full-screen assistant should not share the same response shape or the same instructions -- but they can share the same n8n workflow, because the SSR layer normalizes intent before the LLM sees the text.
The proxy also returns n8n metadata unchanged — the frontend can read workflow hints without the server rewriting them:
The webhook URL defaults in development but production uses env vars only on the server. If the workflow is inactive, n8n returns 404 and the API surfaces a clear error -- failures stay debuggable without opening the browser network tab to a third-party domain.
Layer 1 — Session, resilience, and SSR safety
> In plain terms: What happens in the visitor’s browser when Wi‑Fi drops or the tab reloads — keeping the conversation from resetting randomly. SessionManager generates sess_ IDs with nanoid, persists to sessionStorage, caps history at 50 messages, and returns a fresh session during SSR (typeof window === 'undefined'). Sessions older than one hour roll over automatically.
ChatApiClient mirrors server timeouts (30s), uses AbortController, and implements sendMessageWithRetry with exponential backoff (2 retries by default). Client errors 400/401/403 skip retry -- there is no point hammering a validation failure.
This layer is boring on purpose. Production chat is mostly failure modes: slow LLM, dropped Wi‑Fi, tab backgrounding. The client owns recovery; n8n owns reasoning.
Distributed intent detection — booking state in the frontend
> In plain terms: How the site knows someone is trying to book a call even when the AI speaks in friendly sentences, not database codes. The unusual part of this architecture is where conversation state for booking lives.
n8n runs the dialog: tone, steps, when to ask for name and email. But the frontend watches bot output for keywords and drives calendar APIs:
When the model says "¡perfecto!" and mentions a slot, the UI knows we are in confirm_time and can call /api/calendar/availability or /api/calendar/book with parsed day/time and customer data from regex -- without teaching the LLM your internal enum names.
User messages use a second detector in intentDetector.ts (regex + confidence), separate from bot keywords:
calendarIntentHandler.handleUserMessage then maps dayName + time to POST /api/calendar/select-time, or Name, email, reason to POST /api/calendar/book when pendingBooking exists in metadata.
That split is a trade-off:
- Pros: Change calendar providers or email templates in Astro without redeploying n8n graphs; keep LLM prompts human in Spanish; side effects stay typed TypeScript.
- Cons: Keyword contracts must stay in sync with copy changes in the workflow. Document the phrases; treat them like API versions.
If marketing rewrites bot confirmation copy without updating detectCalendarIntent, availability UI stops firing. This is coupling -- but explicit coupling you control, not a vendor schema.
The production bug that made the warning real
This is not theoretical. In staging, someone tuned the n8n copy to sound friendlier: the bot stopped saying `¡perfecto!` + `cita para` and started with variations like "Excelente" or shorter confirmations without the phrase horarios disponibles. The chat still looked fine in the UI -- messages flowed, the LLM was polite -- but the calendar rail went silent.
detectCalendarIntent returned { intent: 'none' }. No call to /api/calendar/availability. From the user's perspective: they asked for a meeting, the bot answered, and nothing actionable appeared. No stack trace in the browser, because nothing failed at the HTTP layer; the chat endpoint returned 200.
The debug path was unglamorous and fast once you know where to look:
1. Read the bot message string in the network response, not the user's input. 2. Grep that string against calendarIntentHandler.ts. 3. Compare with the live n8n workflow copy (export or editor), not with what marketing thought they changed.
The fix was a three-way sync: restore the trigger phrases in n8n or extend the matcher in TypeScript or both -- then smoke-test the path "ask for availability → see slots → pick time → confirm". We added an informal contract note next to the keywords so the next copy edit does not ship blind.
That incident is the same class of bug as GSAP under Rocket Loader on the Webflow project: a non-obvious coupling between layers that only shows up in production behavior, not in a linter.
The calendar path continues through Google Calendar (googleapis), confirmation tokens, and Resend -- all Astro API routes, documented in the repo under Docs/API_Calendar.md.
Layer 3 — Self-hosted n8n on a VPS
> In plain terms: Where the “thinking” and workflow live on infrastructure the client pays for — and what breaks if someone forgets to turn the workflow on. n8n.cloud would have been faster to start (n8n hosting options (opens in new tab) compare cloud vs self-hosted). We chose a VPS with Docker and Dockploy/Coolify instead because:
- No per-execution billing anxiety on high-traffic chat
- Full workflow export, custom nodes, and private networking to webhooks
- Same region and ops model as other internal automations
The trade-off is operational: you own upgrades, backups, and workflow activation discipline.
How the workflow is structured (without exposing the graph)
You do not need the JSON to understand the arc. The production chat workflow is a straight pipeline:
Webhook receives message, sessionId, optional fileUrl, and metadata (including mode: search | full).
Memory keeps continuity per sessionId so the model does not reset on every turn.
LLM applies Aurin's service context and tone (see Payload section below -- that context lives here, not in the Astro bundle).
Respond returns output / response / message plus optional metadata the frontend may read without the proxy rewriting it.
Calendar confirmation and cron-style cleanup use separate webhooks (for example confirm-appointment), so booking side effects do not compete with the main chat graph.
When the workflow is inactive: 404 end to end
n8n only exposes a webhook while the workflow is Active. If someone deactivates it after an edit, or a deploy leaves the wrong version live, the next message hits a 404 from the webhook URL.
The SSR layer catches that explicitly:
The client receives a failed /api/chat response and surfaces the generic error copy from translations (errorResponse) -- the user sees a polite failure, not a blank widget. In logs you see the 404 immediately; the fix is operational (toggle Active), not a code deploy.
Upgrades without pretending there is zero downtime
Self-hosted n8n on Docker via Dockploy means you own the image, the volume, and the restart. Our practice:
- Export the workflow JSON before any n8n version bump or node change.
- Duplicate the workflow on the same instance, point a test webhook at it, send synthetic payloads from
/api/chatin dev. - Activate the new graph only after a manual conversation test (search mode + full mode + one booking phrase).
- Restart the container in a low-traffic window; expect a short window where chat returns 503/timeout -- monitor Vercel function logs and the first real user message after boot.
We do not run blue-green for n8n today; honesty about a minutes-long blip is cheaper than surprise during a demo.
Spanish and English on the same stack
> In plain terms: How bilingual marketing pages and one chat experience stay aligned without duplicating entire products per language. The intro promised a site in Spanish and English with a controlled brand voice. Here is how that actually splits across layers.
UI layer (Astro + React): Routes follow locale -- Spanish at /, English under /en. ChatbotContainer.astro reads getLangFromUrl and passes lang plus translations[lang].chatbot into the widget: welcome message, placeholders, errors. The hero search bar uses the same pattern with chatbotSearch.services per locale for the typewriter placeholders.
Proxy layer: Search mode prepends a Spanish instruction block today ([MODO BÚSQUEDA: ...]) regardless of page locale. Full chat does not inject language -- the user's message language drives the reply. One n8n workflow serves both locales; we did not fork graphs per language.
Reasoning layer: The model is prompted to answer in the language the user writes. English pages still hit the same webhook with the same sessionId format.
Booking layer (the caveat): detectCalendarIntent and intentDetector keywords are Spanish-first (disponibilidad, ¡perfecto!, weekday names in Spanish). That matches the primary booking market; an English-only bot phrase would not trigger availability until the matcher is extended. Product copy in n8n is kept in Spanish for the booking happy path even on /en, by discipline, not by automatic detection.
Search mode on `/en`: Instructions are Spanish in the SSR prefix, but the user query is English -- the model usually replies in English anyway. A future improvement is metadata.locale from Astro and a bilingual prefix in /api/chat.
Payload CMS and what the agent actually knows
> In plain terms: Who updates website copy versus who updates what the bot is allowed to say — and why those are intentionally separate today. Payload CMS (opens in new tab) is the editorial source for projects, categories, and site content — our admin runs at aurin-payload-cms.vercel.app (opens in new tab). lib/payload.ts fetches that API for Astro pages at build/request time -- the portfolio and service pages reflect what editors publish.
The chat path is different. `/api/chat` does not call Payload on every message. The forward to n8n is only message, sessionId, fileUrl, and metadata. There is no RAG hop in the Astro proxy today — a deliberate split between headless CMS content (opens in new tab) and runtime agent context, similar to how many teams keep marketing copy out of the LLM prompt until they opt into sync jobs.
So how does the bot know Aurin's real services?
- Ground truth for pages: Payload → Astro (what you see on the site).
- Ground truth for conversation: the n8n workflow system prompt and memory (what the model is allowed to say in chat).
When marketing updates a service in Payload, the website updates on deploy; the agent updates when someone refreshes the n8n prompt or knowledge block to match. That is intentional product engineering: decouple fast editorial deploys from a workflow you do not want to auto-trigger on every CMS publish.
The longer-term upgrade is a scheduled n8n job or an HTTP node that pulls /api/... from Payload and refreshes context -- we have not needed it yet because service cardinality is small and changes are infrequent.
Repos stay split on purpose:
- AurinWebsite (opens in new tab) -- Astro, chat, calendar APIs
- aurin-cms (opens in new tab) -- Payload admin and API
The load-bearing decisions
> In plain terms: What paid off for the business, and what I would formalize next for fewer surprises. Decisions that held:
- SSR proxy as the only public integration point
- Mode flags (
search/full) applied server-side - Client retry + session caps
- Self-hosted n8n for cost and control
On the roadmap:
- Formalize the keyword contract (shared doc + CI grep) after the copy-change incident
- Pass
localeinto/api/chatand bilingual search-mode prefixes - Structured
metadata.calendarIntentfrom n8n instead of parsing bot prose - Payload → n8n context sync on publish for services
- Correlate
sessionIdwith n8n execution IDs in logs
References (external — worth bookmarking)
> In plain terms: Official documentation links for readers who want to verify claims or brief their engineering team.
| Topic | Source |
|---|---|
| n8n Webhook node (production vs test, activation) | n8n Docs — Webhook (opens in new tab) |
| Webhook workflow development | n8n Docs — workflow development (opens in new tab) |
| Self-hosted vs cloud hosting | n8n Docs — hosting (opens in new tab) |
| Webhook URL / reverse proxy config | n8n Docs — webhook URL configuration (opens in new tab) |
| Astro API routes (SSR proxy pattern) | Astro — Endpoints (opens in new tab) |
| Server-only secrets in Astro | Astro — Environment variables (opens in new tab) |
| BFF / hide third-party integration from browser | Microsoft Azure — Backends for Frontends (opens in new tab) |
| Google Calendar API | Google — Calendar API overview (opens in new tab) |
| Headless CMS vs runtime agent context | Payload — What is Payload? (opens in new tab) |
| MCP: tools vs opaque backends | Model Context Protocol specification (opens in new tab) |
| MCP remote servers + auth (industry pattern) | Kapa.ai — Remote MCP hosting & authentication (opens in new tab) |
Closing
> In plain terms: The business takeaway: own the conversation path instead of renting a black box. Embedded chatbots optimize for installation. This stack optimizes for ownership: your URLs, your calendar, your modes, your VPS workflows. The interesting engineering is not the widget -- it is the boundary lines: what the browser may know, what the server must hide, and what the automation layer is allowed to decide.
If you are evaluating Intercom for a site that already has Astro SSR and real booking requirements, ask whether the black box will ever be as flexible as three explicit layers you control. On aurin.mx, the answer was no -- so we built the layers instead.