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:
- A low-friction entry point — a floating chat widget that feels approachable, not formal.
- Spam protection without a CAPTCHA — email verification naturally filters out bots and drive-by messages.
- 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:
- A third-party SaaS (Calendly, Typeform, Intercom) that I pay for and do not control.
- A backend server I have to maintain.
- Anything that could break the static IPFS build.
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 build → npx 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:
| State | What the user sees |
|---|---|
closed | Floating bubble button, bottom-right corner |
intro | Panel with First Name + Email fields and a Continue button |
awaiting_code | Frozen banner explaining a code was sent + 6-digit input |
verified_chat | Full chat transcript, free-text input, “Book a call” pill |
picking_slot | Radio-button slot picker (next 7 business days × hourly 09:00–17:00) |
booked | Confirmation 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
- Webhook node receives
{ firstName, email }. - Function node validates the email with a simple regex, generates a
sessionIdviacrypto.randomUUID(), generates a 6-digit numeric code, hashes it with SHA-256, and stores the session in$workflow.staticData.global.sessions[sessionId]:
Old sessions (> 1 hour) are garbage-collected in the same function to keep memory tidy.{ firstName, email, codeHash, // SHA-256 of the raw code codeExpiresAt: Date.now() + 600000, // 10 minutes verified: false, attempts: 0 } - Gmail node (OAuth2, sending from
wakqasahmed@gmail.com) sends:- Subject:
Your verification code: ###### - Body: the raw code, a 10-minute expiry note, signed “Waqas”.
- Subject:
- Respond to Webhook returns
{ sessionId }.
Endpoint: chat-verify-code
- Lookup session by
sessionId. - Increment
attempts. Lock the session after 5 failed attempts. - Check
codeExpiresAt > Date.now(). - Compare
sha256(code) === codeHash. - On success: set
verified = true, respond{ ok: true }. - 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
- Verify the session is authenticated.
- Read
portfolio-context.mdfrom n8n’s filesystem (uploaded once as a binary file via n8n’s binary-data feature — not sent from the browser on every request). - 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 } - 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
- Verify session.
- Compute candidate slots: next 7 weekdays, hourly 09:00–17:00 in
Europe/Nicosia, 30-minute duration. - Google Calendar Free/Busy node queries
wakqasahmed@gmail.comacross the same window. - Filter: remove slots that overlap a busy interval, drop anything within 4 hours of now, cap at 12 results.
- Format human labels:
"Tue, May 5 · 14:00 EEST". - Respond
{ slots: [{ startISO, endISO, label }] }.
Endpoint: chat-book-slot
- Verify session.
- Re-run free/busy to confirm the chosen slot is still available (prevents double-booking between the list call and the confirm call).
- 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
- Summary:
- 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
- A self-hosted n8n instance (Docker Compose works well; n8n publishes an official image).
- A Google Cloud project with the Gmail API and Calendar API enabled, and an OAuth 2.0 client configured.
- An NVIDIA NIM API key from build.nvidia.com (free tier, no credit card).
- An Astro static site (or any static site framework — the chatbot is just HTML/JS calling webhooks).
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
- Import the workflow JSON from
n8n/portfolio-chatbot.jsonin this repository. - Add your Gmail OAuth2 credential and link it to the Gmail node.
- Add your Google Calendar OAuth2 credential and link it to the Calendar nodes.
- Set
NVIDIA_API_KEYin n8n’s environment. - Upload your
portfolio-context.mdas a binary file (n8n Settings → Binary Data). - 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:
- Greets visitors by name after they verify their email.
- Answers questions about your work grounded in your actual portfolio content, in your voice, powered by a frontier LLM at zero marginal cost.
- Books 30-minute calls directly into your calendar, sends the Google Calendar invite to the visitor automatically, and confirms in the chat.
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.