Tech Specs
Personal portfolio and AI-native sandbox for Ricardo Fernández Gasca. Last updated: October 9, 2026..
1. Stack Overview
| Layer | Technology | Version |
|---|---|---|
| Framework | Next.js (App Router) | 16.4.0 |
| Language | TypeScript | ^5 |
| UI Library | React | 19.2.3 |
| Styling | Tailwind CSS v4 | ^4 |
| Animations | Framer Motion | ^12 |
| Fonts | Inter + JetBrains Mono | Google Fonts (via next/font) |
| MDX Rendering | next-mdx-remote | ^6 |
| Deployment | Vercel | — |
| Node Requirement | Node.js | ≥ 22.x |
| Testing | Vitest | pure-module suite |
| CI | GitHub Actions | every pull request |
Every pull request runs an automated check — lint, type-check, a Vitest suite covering the pure logic (permission matrices, date and aggregation math, validators) and a full production build — and must pass it, plus an automated code review, before it can be merged. Code changes are merged by hand once both pass.
2. Architecture
┌─────────────────────────────────────────────────────────┐
│ Next.js App Router │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│ │ Pages │ │ API Routes│ │ Static / MDX Content │ │
│ │ / │ │ /api/chat │ │ Notion → sync scripts│ │
│ │ /library │ │ /api/chat/│ │ → .md files │ │
│ │ /work │ │ conv. │ │ → gray-matter parse │ │
│ │ /chat │ │ │ │ │ │
│ │ /contact │ │ /api/contact│ │ │ │
│ └──────────┘ └──────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────┘
│ │
▼ ▼
Upstash Redis Google Gemini API
(rate limits, (Dango streaming
sessions, KV) + tool calls)
│
▼
Notion API
(visitor data +
conversation logs)
Routing
- Static content (library, work, writing) compiles at build time from Notion-synced MDX.
- Dynamic chat (
/api/chat) is forced dynamic (export const dynamic = "force-dynamic") to prevent response buffering. - Redirects in
next.config.ts:/writing → /library,/interests → /library.
3. Content System
Content is authored in Notion and synced via three CLI scripts:
| Script | Source | Output |
|---|---|---|
sync:posts | Notion posts DB | content/posts/{locale}/ |
sync:pages | Notion pages DB | content/pages/{locale}/ |
sync:work | Notion work DB | content/work/*.json |
Markdown frontmatter is extracted with gray-matter. MDX rendering uses next-mdx-remote. Locales supported: en, es, fr.
4. Internationalization
- Three locales: English, Spanish, French.
- Client-side language selection via React Context.
- Translation strings centralized.
- AI assistant (Dango) auto-detects visitor language from the active interface locale and adapts mid-conversation.
5. Dängo — AI Assistant
Dängo is the site's native conversational AI agent. Not a chatbot widget, not a form with a personality — a purpose-built streaming agent with a multi-layered identity, live tool-calling, visitor interaction management, and a defense-in-depth security model baked in at every layer.
The name comes from grandeza in Otomí. It's not aesthetic. It's identity.
5.1 Model & Capacity
| Parameter | Value |
|---|---|
| Model | gemini-3.8-flash |
| Max output tokens | ~750 words |
| Max conversation context | ~5 000 tokens |
5.2 Identity Architecture
Dängo's behavior is not a single prompt. It is assembled server-side on every request from layered instruction blocks, combining live context, personality, policy, and behavioral rules:
┌─────────────────────────────┐
│ Datetime hint (injected) │ ← Current UTC datetime, always fresh
├─────────────────────────────┤
│ Locale hint (injected) │ ← Active interface language
├─────────────────────────────┤
│ Identity layer │ ← Personality, tone, cultural context
├─────────────────────────────┤
│ Policy layer │ ← Hard boundaries and refusal logic
├─────────────────────────────┤
│ Operational layer │ ← How and when to use tools
└─────────────────────────────┘
The full assembled instruction set is never included in the HTTP response or exposed to the client in any form.
5.3 Tool System
Dängo can call server-side tools mid-stream. Tools are executed transparently — the visitor never sees the mechanism, only the result woven into the response.
Available capabilities include:
- Retrieving published content from the site to answer questions accurately
- Capturing visitor contact information and intent when someone expresses genuine interest
All tool inputs are sanitized (control character stripping, length capping, format validation) before any write operation.
5.4 Language Adaptation
Dängo defaults to Spanish. If the visitor's interface is set to English or French, the locale hint is injected at the top of the instruction set and Dängo opens in that language. Mid-conversation language switches are also detected and matched — no configuration needed by the visitor.
5.5 Streaming & Tool Calls
Responses stream token-by-token via Server-Sent Events. If a tool call is triggered mid-stream, the stream pauses, the tool executes server-side, and generation resumes — the entire cycle is invisible to the client. There is no polling, no loading state between tool execution and response.
5.6 Conversation Logging
Every session is logged on close. Close triggers include: session limit reached, visitor navigates away, or a contact interaction is completed. Each log captures:
- Session identifier and timestamp
- Close reason
- Full message history
- All tool invocations with their inputs and outputs
- Model reasoning traces (when emitted during generation)
Logs are stored in a private Notion workspace, not accessible from the public site.
6. State Management
| Concern | Mechanism |
|---|---|
| UI language | React Context (client) |
| Dängo presence & animation mode | React Context (client) |
| Chat history | Component-local state (client) |
| Rate limit counters | KV Store (server-side) |
| Session message counts | KV Store (server-side) |
Dängo's client-side presence cycles through four animation states: idle, thinking (waiting for first token), streaming (tokens arriving), and navigating (animating toward a page transition).
7. KV Store Abstraction
All server-side stateful operations (rate limits, sessions) go through a KVStore interface:
interface KVStore {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
del(key: string): Promise<void>;
}
| Environment | Implementation | Backend |
|---|---|---|
| Production | UpstashStore | Upstash Redis (REST API) |
| Development / CI | MemoryStore | In-process Map |
The backend is selected at module load time based on the presence of Redis connection credentials in the environment. Swapping stores requires no changes to security or session code. The in-memory store auto-prunes expired entries every 2 minutes and uses unref() to avoid blocking serverless process exit.
8. Security
8.1 Request Pipeline
Every POST /api/chat request traverses eight ordered gates before reaching the model:
1. JSON parse & shape validation
2. Message budget validation (character counts)
3. IP rate limit ← fail fast
4. IP daily limit ← closes session-rotation loophole
5. Origin validation ← blocks curl/bot requests
6. Turnstile CAPTCHA ← first message of new sessions only
7. Session message limit ← per-session cap with TTL
8. Injection scan ← latest user message scanned pre-LLM
8.2 Security Measures Table
| Measure | Mechanism | Configuration |
|---|---|---|
| IP sliding-window rate limit | Async sliding window, store-backed | X req / Y min |
| Burst protection | Sub-window check inside sliding window | X req / Y s |
| IP daily budget | 24 h sliding window | X req / Y h |
| Session message cap | Per-session counter, store-backed with base + bonus turn budget | Configurable hard ceiling per session |
| Session TTL | Auto-expiration via KV TTL | X h of inactivity |
| CAPTCHA | Cloudflare Turnstile, server-side siteverify | Required on first message of each session |
| Prompt injection scanning | Pre-LLM regex + structural checks | 35+ patterns across X attack categories |
| Injection structural guard | Length cap + repeated-char detection | Max X chars; X + repeated chars flagged |
| Origin validation | Custom header + Fetch Metadata + Origin/Referer check | Production only |
| System prompt confidentiality | Prompt assembled and discarded server-side; never returned in response | Policy layer enforces behavior at the model level |
| Fail-closed CAPTCHA | Missing CAPTCHA secret in production → refuse request | Dev: skip; Prod: hard fail |
| Cache-Control on errors | All error responses set Cache-Control: no-store | Prevents CDN caching of auth errors |
| Input sanitization (tools) | Control-char strip, length cap, email regex | Applied before every Notion write |
8.3 Prompt Injection Patterns Covered
| Category | Examples |
|---|---|
| Role override / reset | "Ignore previous instructions", "You are no longer…", "New persona" |
| Instruction extraction | "Reveal your system prompt", "Print your instructions" |
| Jailbreak keywords | jailbreak, DAN mode, developer mode, god mode, unrestricted mode |
| Bypass/override framing | "Bypass your filters", "Disable your safety", "Override your rules" |
| Simulation attacks | "Simulate a different AI", "Act as if you are a different model" |
| Context flooding | Repeated-character strings (>100 repetitions) |
| Message length abuse | Messages exceeding 15 000 characters |
9. Notion Integration
Notion serves a dual role: content management system for public-facing content, and private backend for visitor interaction data.
Content (Notion → site):
| Content type | Sync mechanism |
|---|---|
| Blog / library posts | CLI sync script, per-locale Markdown files |
| About / work pages | CLI sync script, per-locale Markdown files |
| Work capabilities & philosophy | CLI sync script, structured JSON |
Visitor interactions (site → Notion, private):
| Interaction type | Trigger |
|---|---|
| Contact & collaboration interest | Captured by Dängo during conversation |
| Full conversation logs | Written automatically on session close |
No visitor data is ever surfaced back to the public site. All write operations go to private Notion workspaces inaccessible from the front-end.
10. External Service Credentials
The application requires credentials for five external services, injected at runtime via environment configuration. No secrets are bundled into the build artifact.
11. The Private Client — /dango
Everything documented above is public. Behind a login there is a second half of the site: the same Dängo, plus persistence, plus two products that are not chat at all.
11.1 Authentication
Identity is handled by Supabase Auth — email and password, with sessions issued and refreshed through server-side cookies. Registration is invite-only: an admin generates a single-use link, the visitor opens it, picks a username and password, and is signed in immediately. The token cannot be reused.
11.2 What an Account Adds
| Capability | Behaviour |
|---|---|
| Conversation history | Every past conversation is stored, expandable, and can be continued — prior turns reload as context |
| Long-term memory | After a session closes, Dängo extracts a handful of facts about you, embeds them, and retrieves the relevant ones at the start of the next session |
| Personalised behaviour | Who you are and your relation to me are injected into the instruction stack, and shape Dängo's register |
| Per-user model | Each account is assigned a model from the catalog |
| Quotas | Per-user message and token limits, enforced server-side before the model is reached; any dimension left unset is unlimited |
| Tool tiers | Some tools are public, some admin-only, and some granted per user |
Authenticated sessions skip the CAPTCHA and the anonymous IP limits — they are bounded by the account's own quota instead. Every successful turn writes an immutable usage record.
11.3 Admin
Admins get a management panel over the whole user base: assigned model, quotas, tool and feature grants, conversation counts, monthly token usage, and the invite generator. Changes apply immediately.
12. Workflows — Document Extraction
A pipeline that turns a folder of documents into one reviewable spreadsheet.
Google Drive picker (your own OAuth)
→ download server-side
→ Document AI OCR (inline, no storage bucket)
→ Gemini structured extraction (OCR text + page image, JSON mode)
→ flatten: one document → one or more rows
→ aggregate → CSV / XLSX
Documents are processed in small batches and streamed back over Server-Sent Events, so long jobs report progress. Rows are persisted per document: a dropped connection loses no completed work, and re-running a single file replaces only that file's rows.
Each document type is a use case — a self-contained contract of four parts: the prompt, the typed response schema the model must return, how one document flattens into rows, and the output columns. Everything else (OCR, batching, export, UI) is shared, so adding a document type adds no pipeline code. Use cases come either from version-controlled code, or from a builder in the admin panel that needs no deploy.
| Bound | Value |
|---|---|
| Documents per job | 200 |
| OCR ceiling per document | ~20 MB / 15 pages |
| Drive access | Your own OAuth grant, limited to the files you pick |
Source files are read with your own Drive authorisation and are never copied into a storage bucket — only the extracted rows are kept. Access is granted per document type.
13. Food — Meal Planning
A household meal-planning module: a recipe library, weekly plans, shopping lists, and a conversational kitchen assistant that writes recipes from a photo, a PDF, a pasted block of text, or just an idea. Private tooling — the pages are not indexed.
Access has two independent axes, and keeping them separate is the point: a grant decides whether you can open the module at all, and your role inside a household (owner, editor, shopper, viewer) decides what you can do there. A single role→action matrix is shared by the server and the UI so the two cannot drift. Being a site admin does not make you a member of someone's household.
Shopping lists can be shared through a public, unindexed link, so whoever is actually at the shop doesn't need an account.
14. Access — and How to Ask for It
Open to everyone, no account: the public pages, this spec, and /chat — Dängo with its full tool set, rate-limited and CAPTCHA-gated but not behind a login.
Behind an invite: sections 11, 12 and 13. Registration is invite-only by design, not by oversight — every authenticated feature spends tokens against a real budget, and long-term memory means the system keeps notes on the people who use it. Both are reasons to know who is on the other side.
Access is layered rather than all-or-nothing:
| Surface | Requires |
|---|---|
Public site, /chat | Nothing |
/dango — chat, history, memory | An account (invite) |
| A workflow document type | An account + that workflow's grant |
| Food | An account + the Food grant + a household membership |
Want in? Say what you'd use it for — a workflow for a document type you deal with, a household to plan meals for, or plain curiosity about the memory layer.
→ Request access — one click, opens the contact form with the request already written.