wakqasahmed.eth
← All posts

Building an Email-Gated Booking Chatbot on a Decentralized Portfolio

Web3n8nChatbotIPFSENSNVIDIA NIMAstroGoogle CalendarAutomation

My portfolio lives at wakqasahmed.eth — a static Astro site pinned to IPFS and resolved through an ENS name. No server. No database. No hosting bill. The site is censorship-resistant, content-addressed, and permanently archived the moment it is pinned.

That creates an interesting constraint: how do you add dynamic features — contact, Q&A, calendar booking — to something that is fundamentally a set of files on a peer-to-peer network?

This post covers exactly that. By the end you will understand both why I built it this way and how you can replicate every piece of it.


The Business Problem

Every developer portfolio has the same silent failure mode: a visitor wants to reach you, clicks nothing because the friction is too high, and leaves. Email links get scraped. Contact forms attract spam. LinkedIn DMs disappear in noise.

I wanted three things:

  1. A low-friction entry point — a floating chat widget that feels approachable, not formal.
  2. Spam protection without a CAPTCHA — email verification naturally filters out bots and drive-by messages.
  3. A path to a real conversation — book a 30-minute call directly from the chat, without going back and forth on calendars.

And I wanted all of this without:

The solution: the frontend is pure HTML/JS calling webhooks. All stateful logic — email sending, code verification, LLM inference, calendar booking — runs inside a self-hosted n8n instance I already operate for other automations.


Architecture Overview

Browser (IPFS-hosted Astro static site)

  │  POST /webhook/chat-*  (JSON over HTTPS)

n8n (self-hosted, your server or VPS)
  ├── Gmail OAuth  →  sends 6-digit code
  ├── Workflow Static Data  →  session store (no DB needed)
  ├── NVIDIA NIM API  →  Llama 3.1 70B answers questions
  └── Google Calendar API  →  free/busy query + event creation

The frontend never sees API keys, never touches a database, and has no server of its own. Every sensitive operation happens inside n8n, which you host and control.


The Decentralized Foundation

Before diving into the chatbot, it helps to understand the hosting stack it sits on top of.

IPFS (InterPlanetary File System) is a content-addressed peer-to-peer network. When you pin a folder to IPFS, every file gets a unique hash (CID). Anyone who has the CID can retrieve the file — no central server required. npm run buildnpx ipfs-car pack dist/ → pin to your IPFS node or a pinning service like Pinata.

ENS (Ethereum Name Service) is a decentralised naming system on the Ethereum blockchain. Instead of remembering a 46-character IPFS hash, you set a contenthash record on your ENS name (wakqasahmed.eth) pointing to the latest CID. Update it on-chain whenever you redeploy.

eth.limo is a gateway that resolves ENS names to IPFS content over normal HTTPS — so wakqasahmed.eth.limo works in any browser, no extension required.

The chatbot widget is just a component in the static Astro site. It calls external webhooks from the browser. IPFS does not care what JavaScript your HTML runs.


Step 1 — The Chat Widget (Astro Frontend)

The widget is a single Astro component: src/components/Chatbot.astro. It is mounted in src/layouts/Layout.astro after <slot /> so it overlays every page.

State machine

The widget has six states, persisted in localStorage so a page refresh does not reset the conversation:

StateWhat the user sees
closedFloating bubble button, bottom-right corner
introPanel with First Name + Email fields and a Continue button
awaiting_codeFrozen banner explaining a code was sent + 6-digit input
verified_chatFull chat transcript, free-text input, “Book a call” pill
picking_slotRadio-button slot picker (next 7 business days × hourly 09:00–17:00)
bookedConfirmation with event link and “Add to calendar”

Environment variable

The only configuration the frontend needs is the base URL of your n8n instance:

PUBLIC_N8N_WEBHOOK_BASE=https://your-n8n.example.com

Set this in .env.local for development and in your build pipeline for production.

Webhook calls

All five transitions call POST ${PUBLIC_N8N_WEBHOOK_BASE}/webhook/<path>:

chat-request-code  { firstName, email }            → { sessionId }
chat-verify-code   { sessionId, code }             → { ok, reason? }
chat-ask           { sessionId, message, history } → { answer }
chat-list-slots    { sessionId }                   → { slots[] }
chat-book-slot     { sessionId, slotISO, notes }   → { ok, eventLink, summary }

The sessionId is a UUID generated by n8n on the first call and stored in localStorage. It ties all subsequent calls to one verified session.

Styling

The widget follows the portfolio’s master theme: Lora serif for bot responses (italic, left-aligned), Inter for user input, #3D63AE (primary blue) for sent messages, bg-hero-dark for received messages. The panel is 420 × 560 px on desktop and a full-screen bottom sheet on mobile.


Step 2 — Email Verification (n8n)

Endpoint: chat-request-code

  1. Webhook node receives { firstName, email }.
  2. Function node validates the email with a simple regex, generates a sessionId via crypto.randomUUID(), generates a 6-digit numeric code, hashes it with SHA-256, and stores the session in $workflow.staticData.global.sessions[sessionId]:
    {
      firstName, email,
      codeHash,                          // SHA-256 of the raw code
      codeExpiresAt: Date.now() + 600000, // 10 minutes
      verified: false,
      attempts: 0
    }
    
    Old sessions (> 1 hour) are garbage-collected in the same function to keep memory tidy.
  3. Gmail node (OAuth2, sending from wakqasahmed@gmail.com) sends:
    • Subject: Your verification code: ######
    • Body: the raw code, a 10-minute expiry note, signed “Waqas”.
  4. Respond to Webhook returns { sessionId }.

Endpoint: chat-verify-code

  1. Lookup session by sessionId.
  2. Increment attempts. Lock the session after 5 failed attempts.
  3. Check codeExpiresAt > Date.now().
  4. Compare sha256(code) === codeHash.
  5. On success: set verified = true, respond { ok: true }.
  6. On failure: respond { ok: false, reason: 'invalid_code' | 'expired' | 'locked' }.

No external database. n8n Workflow Static Data persists across executions as long as the workflow is active.


Step 3 — LLM Q&A with NVIDIA NIM

Why NVIDIA NIM

NVIDIA’s inference microservices offer a free tier at build.nvidia.com with an OpenAI-compatible API. Llama 3.1 70B Instruct fits 128K context — more than enough to include the entire portfolio’s Markdown content as a system prompt. No fine-tuning, no vector database, no embeddings pipeline.

Endpoint: chat-ask

  1. Verify the session is authenticated.
  2. Read portfolio-context.md from n8n’s filesystem (uploaded once as a binary file via n8n’s binary-data feature — not sent from the browser on every request).
  3. POST to https://integrate.api.nvidia.com/v1/chat/completions:
    {
      "model": "meta/llama-3.1-70b-instruct",
      "messages": [
        {
          "role": "system",
          "content": "You are Waqas Ahmed's portfolio assistant. Answer concisely in his voice — technical, friendly, direct. Ground every answer in the CONTEXT below; if unknown, say so.\n\nCONTEXT:\n{portfolio-context.md}"
        },
        ...history,
        { "role": "user", "content": "{message}" }
      ],
      "temperature": 0.4,
      "max_tokens": 600
    }
    
  4. Extract choices[0].message.content, respond { answer }.

Set NVIDIA_API_KEY as an n8n environment variable. The key never touches the browser.

Building the portfolio context file

A small Node script (scripts/build-portfolio-context.mjs) reads all Markdown files from src/content/{experience,education,projects,blog}/, strips YAML frontmatter, and writes the concatenated result to portfolio-context.md. This runs as a prebuild step:

"scripts": {
  "prebuild": "node scripts/build-portfolio-context.mjs",
  "build": "astro build"
}

Upload the resulting file to n8n once (or automate it as part of your CI/CD pipeline via n8n’s REST API).


Step 4 — Google Calendar Booking (n8n)

Endpoint: chat-list-slots

  1. Verify session.
  2. Compute candidate slots: next 7 weekdays, hourly 09:00–17:00 in Europe/Nicosia, 30-minute duration.
  3. Google Calendar Free/Busy node queries wakqasahmed@gmail.com across the same window.
  4. Filter: remove slots that overlap a busy interval, drop anything within 4 hours of now, cap at 12 results.
  5. Format human labels: "Tue, May 5 · 14:00 EEST".
  6. Respond { slots: [{ startISO, endISO, label }] }.

Endpoint: chat-book-slot

  1. Verify session.
  2. Re-run free/busy to confirm the chosen slot is still available (prevents double-booking between the list call and the confirm call).
  3. Google Calendar Create Event node:
    • Summary: Call with {firstName} ({email}) — portfolio chatbot
    • Start/end: slotISO + 30 minutes
    • Attendees: [email]
    • sendUpdates: 'all' — Google sends the invite automatically
  4. Respond { ok: true, eventLink, summary, startISO }.

Both Google nodes use the same OAuth2 credential configured once in n8n’s Credentials panel. No Google Cloud project setup beyond enabling the Calendar API and creating an OAuth client.


Security Considerations

CORS: Lock Access-Control-Allow-Origin in n8n’s webhook response headers to https://wakqasahmed.eth.limo and http://localhost:4321. This prevents other sites from calling your webhooks.

Rate limiting: Implement in n8n’s Static Data — track rateLimit[email].count and rateLimit[email].windowStart. Reject chat-request-code if the same email has requested more than 3 codes in an hour.

Code locking: After 5 failed verification attempts, mark the session locked: true. A locked session cannot be verified; the user must request a new code.

Input length caps: Truncate message to 2000 characters before injecting into the LLM prompt. The payload is JSON, so there is no injection risk, but length caps prevent runaway context costs.

Session expiry: Sessions older than 1 hour are garbage-collected during each chat-request-code call.


How to Replicate This

Prerequisites

Environment variables

# .env.local (frontend)
PUBLIC_N8N_WEBHOOK_BASE=https://your-n8n.example.com

# n8n environment variables (set in n8n's Settings → Environment)
NVIDIA_API_KEY=nvapi-...

n8n setup steps

  1. Import the workflow JSON from n8n/portfolio-chatbot.json in this repository.
  2. Add your Gmail OAuth2 credential and link it to the Gmail node.
  3. Add your Google Calendar OAuth2 credential and link it to the Calendar nodes.
  4. Set NVIDIA_API_KEY in n8n’s environment.
  5. Upload your portfolio-context.md as a binary file (n8n Settings → Binary Data).
  6. Activate the workflow. All five webhook paths will go live immediately.

CORS configuration

In n8n’s webhook response nodes, add:

Access-Control-Allow-Origin: https://your-site.example.com
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type

Add a separate OPTIONS webhook path for each endpoint to handle preflight requests.

Deploy the frontend

npm install
npm run build    # runs prebuild (context builder) then astro build
npx ipfs-car pack dist/ --output dist.car
# pin dist.car to your IPFS node or Pinata
# update contenthash on your ENS name

What You End Up With

A floating chat widget that:

All of this runs on a static file bundle that costs nothing to host, cannot be taken down by a hosting provider, and degrades gracefully (the rest of the portfolio works fine if n8n is unreachable).

The total recurring cost: the electricity for your n8n server (or a €5/month VPS you probably already run).


If you replicate any part of this, I would genuinely like to know. Reach out through the chatbot — it is right there in the corner.