The CV site includes an AI-powered conversational assistant (the "mascot") that lets visitors ask natural language questions about the CV content. Built with [Google ADK Go 1.0](https://github.com/google/adk-go) (Agent Development Kit) and Gemini AI, it provides instant, accurate answers by querying the same cached JSON data that renders the site.
The mascot appears as a floating robot icon in the bottom-right corner of the page. Clicking it opens a chat panel where visitors can type questions or click suggested question chips. All answers are sourced from real CV data — no hallucination, no stale data.
**Why it exists:** A CV is a dense document. Visitors (recruiters, hiring managers) often have specific questions: "Does he know React?", "How many years of experience?", "What certifications?". Instead of making them scan every section, the mascot lets them ask directly and get precise, cross-referenced answers.
**Live example:** A visitor asks *"What is Juan's experience with Go?"* and gets a response listing Go projects (Immich Photo Manager, Cmux Resurrect), skill categories where Go appears, and experience entries involving Go — all pulled from the actual CV data in real time.
1.**User clicks a chip or types a question** in the chat panel.
2.**Hyperscript** sets the input value (for chips) and triggers `submit` on `#chat-form`.
3.**HTMX** intercepts the form submit and sends `POST /api/chat` with `message`, `session_id`, and `lang` fields.
4.**Go handler** (`chat.HandleChat`) receives the request, ensures a session exists, and creates an ADK `runner.Run()` call.
5.**ADK Runner** sends the message to Gemini along with the agent instruction and available tools.
6.**Gemini calls `query_cv`** with appropriate `section` and `query` parameters (the agent decides which sections to query based on its instruction strategy).
7.**`query_cv` tool** searches the cached CV JSON data (`cache.DataCache`) — the same data that renders the HTML pages. For technology queries, it performs cross-section search across experience, projects, skills, and courses simultaneously.
8.**Gemini synthesizes** the tool results into a natural language response.
9.**Handler renders** the response as an HTML fragment: user message bubble + agent message bubble + session ID hidden input.
10.**HTMX swaps** the fragment into `#chat-messages` with `beforeend` swap and auto-scrolls to the bottom.
11.**OOB swap** updates the `#chat-session-id` hidden input so subsequent messages maintain conversation context.
**Why a single agent?** The CV data is structured and bounded. There is no need for multi-agent orchestration. One agent with one tool is the right abstraction: simple, fast, predictable.
- **`NewHandler()`** — Initializes Gemini model, creates the agent, sets up in-memory session service and ADK runner. Returns a disabled handler if `GOOGLE_API_KEY` is not set.
- **`Enabled()`** — Boolean check used by templates to conditionally render the widget.
- **`HandleChat()`** — Processes `POST /api/chat`. Validates input, ensures session exists, runs the agent with a 30-second timeout (using a dedicated context, not the HTTP request context), renders the HTML fragment response.
- **`formatResponse()`** — Converts basic markdown to HTML: escapes HTML entities first, then applies `**bold**` to `<strong>`, converts `- ` bullet lines to `<ul><li>`, and wraps text in `<p>` tags.
### `templates/partials/widgets/chat-widget.html`
The HTMX + Hyperscript chat UI. Conditionally rendered with `{{if .ChatEnabled}}`. Contains:
- **Toggle button** — Fixed position, Hyperscript toggles `.chat-open` on the panel and `.mascot-active` on itself.
- **Chat header** — Blue bar with robot icon, title (bilingual), and help button that opens the help modal via `command="show-modal"`.
6.**How it works** — Brief explanation of ADK Go + Gemini powering the assistant
Each section contains 3-4 example questions in both English and Spanish (toggled by `{{if eq .Lang "es"}}`).
### `static/css/04-interactive/_chat.css`
Complete styling for the chat widget. See sections 10 and 11 for design system and dark theme details.
## 4. The `query_cv` Tool
The `query_cv` tool is the agent's only way to access CV data. It accepts three parameters:
| Parameter | Type | Description |
|-----------|------|-------------|
| `section` | string | Which CV section to query (see values below) |
| `query` | string | Keyword filter. Empty returns all items in the section. |
| `language` | string | `"en"` or `"es"`. Defaults to `"en"`. |
### Section Values
| Section | Returns | Filter Behavior |
|---------|---------|-----------------|
| `search` | Cross-section results (map with keys: experience, projects, skills, courses) | Case-insensitive keyword match across ALL four sections simultaneously |
| `experience` | `[]Experience` | Filters by company, position, location, dates, technologies, responsibilities, short description |
| `projects` | `[]Project` | Filters by title, short description, location, technologies, responsibilities |
| `skills` | `[]SkillCategory` | Filters by category name and individual skill items |
| `education` | `[]Education` | Returns all (no filtering) |
| `languages` | `[]Language` | Returns all (no filtering) |
| `certifications` | `[]Certification` | Returns all (no filtering) |
| `awards` | `[]Award` | Returns all (no filtering) |
| `summary` | `{summary, years_of_experience}` | Returns the professional summary and calculated years |
| `all` | `{experience_count, project_count, skill_categories, ...}` | Returns high-level counts across all sections |
The tool reads from `cache.DataCache` — the same in-memory cache that powers the website rendering. Zero additional I/O, zero data duplication.
## 5. Cross-Section Search
When `section="search"`, the tool performs a simultaneous keyword search across four sections:
1.**Experience** — Matches in company name, position, location, dates, technologies list, responsibilities list, and short description.
2.**Projects** — Matches in title, short description, location, technologies list, and responsibilities list.
3.**Skills** — Matches in skill category name (e.g., "Languages", "DevOps") and individual skill items.
4.**Courses** — Matches in title, institution, and description.
### Why Cross-Section Search Matters
Technology queries are the most common use case, and technologies can appear in multiple sections. For example, asking about "Java":
- **Experience**: Appears in 5+ job entries where Java was used
- **Projects**: May appear in project tech stacks
- **Skills**: Listed under "Programming Languages" with proficiency level
- **Courses**: May appear in training course titles
Without cross-section search, the agent would need to make 4 separate tool calls. With `section="search"`, a single call returns all matches organized by section, giving the agent complete context to synthesize a comprehensive answer.
Only sections with matches are included in the result.
## 6. Agent Intelligence
The agent instruction defines query strategies for 8 question types. This is the core of the agent's intelligence — it tells Gemini exactly which section(s) to query for each type of question.
| # | Question Type | Query Strategy | Example |
|---|---------------|----------------|---------|
| 1 | **Technology** (Java, Go, React, Docker) | `section="search"` with technology name | "Has he worked with React?" -> `search`, query=`"react"` |
| 2 | **Company / Employer** | List all: `section="experience"` no query. Specific: `section="search"` with company name. | "What companies?" -> `experience`. "Tell me about SAP" -> `search`, query=`"sap"` |
| 3 | **Years / Career Overview** | `section="summary"` for years. `section="all"` for overview. | "How many years?" -> `summary` |
| 4 | **Projects** | List all: `section="projects"` no query. By tech: `section="search"`. | "Go projects?" -> `search`, query=`"go"` |
| 8 | **Language Proficiency** | `section="languages"` | "What languages does he speak?" -> `languages` |
### Bonus Context in the Instruction
The agent instruction also tells Gemini:
- The CV website itself is built with Go, HTMX, Hyperscript, and vanilla CSS — a real-world showcase of Juan's skills.
- The chat assistant is powered by Google ADK Go 1.0 and Gemini AI — another demonstration of Go expertise.
- For general questions ("tell me about Juan"), use `summary` first, then `all`.
### Language Behavior
The agent is instructed to respond in the same language the user writes in. If the user asks in Spanish, the response comes back in Spanish. This is handled entirely by Gemini's multilingual capabilities — no language detection code is needed.
2.`set #chat-input.value to '...'` writes the full question into the text input.
3.`trigger submit on #chat-form` dispatches a native `submit` event on the form element.
4. HTMX intercepts the submit event (because the form has `hx-post`) and sends the POST request.
This approach was chosen over inline `onclick` with `htmx.trigger()` because `htmx.trigger()` expects a DOM element reference, not a CSS selector string. Hyperscript's `trigger <event> on <element>` syntax works natively with HTMX's event listening.
## 8. Help Modal
The help modal (`chat-help-modal.html`) is a native `<dialog>` element opened via the `?` button in the chat header using the Invoker Commands API (`commandfor="chat-help-modal" command="show-modal"`).
### Structure
The modal contains 6 sections with 3-4 example questions each:
5.**About Skills** (star icon) — Technical skills, specific tools
6.**How it works** (info icon) — Brief explanation of the AI powering it
All text is bilingual (English/Spanish) using Go template conditionals. The modal uses the same `info-modal` CSS classes as other site modals (keyboard shortcuts, etc.) for visual consistency.
### Closing Mechanism
The modal closes via:
- The X button (`commandfor="chat-help-modal" command="close"`)
- Clicking the backdrop (Hyperscript: `_="on click call closeOnBackdrop(me, event)"`)
The chat widget integrates with the CV site's existing design system, using the same CSS custom properties (design tokens) defined in `_variables.css`.
- **Toggle button**: Fixed, `bottom: 6rem`, `right: 2rem`, 50px circle. Positioned just above the back-to-top button.
- **Chat panel**: Fixed, `bottom: 10.5rem`, `right: 2rem`, 360px wide, max 500px tall. Above the toggle button.
- **Shadow**: `var(--shadow-lg)` for the panel, custom shadow for the button.
- **Border radius**: 8px for the panel, 50% for the button, 8px for message bubbles (with 2px on the pointed corner), 14px for chips, 16px for the input.
### Responsive (Mobile)
At `max-width: 480px`:
- Panel goes full-width, bottom-anchored with top rounded corners.
- Button moves to `bottom: 5rem`, `right: 1rem`.
- Messages area reduces to `max-height: 200px`.
## 11. Dark Theme
The site's dark theme class (`.theme-clean`) triggers a complete color override for the chat widget:
| Element | Light | Dark |
|---------|-------|------|
| Panel background | `#ffffff` | `#1a1a1a` |
| Panel border | `#e0e0e0` | `#333333` |
| Header | `#0066cc` | `#003d7a` |
| Agent bubble | `#f5f5f5` | `#2a2a2a` |
| Agent text | `#333333` | `#d0d0d0` |
| User bubble | `#0066cc` | `#004d99` |
| Error bubble bg | `#fef2f2` | `#3a1010` |
| Error text | `#991b1b` | `#fca5a5` |
| Error border | `#fecaca` | `#5a1a1a` |
| Input area bg | `#ffffff` | `#1a1a1a` |
| Input bg | (same) | `#111111` |
| Input border | `#e0e0e0` | `#333333` |
| Chip text | `#666666` | `#999999` |
| Chip border | `#e0e0e0` | `#333333` |
| Chip hover bg | `#0066cc` | `#004d99` |
| Typing dots | `#999999` | `#555555` |
All dark theme rules are scoped under `.theme-clean` to avoid conflicts with the default light theme.
## 12. Session Management
The chat uses ADK Go's built-in `session.InMemoryService()` to maintain conversation context across multiple messages.
### Session Lifecycle
1.**First message**: No `session_id` in the form. Handler sets `sessionID = "default"` then tries to `Get()` it.
2.**Session not found**: Handler calls `session.Create()` which returns a new session with a UUID.
3.**Response includes session ID**: An OOB-swapped hidden input is appended to the response:
4. **Subsequent messages**: The form now includes the session ID. The handler calls `Get()` which succeeds, and the conversation continues with context.
### Key Properties
- **In-memory only**: Sessions are not persisted to disk. Server restart clears all sessions.
- **Per-visitor isolation**: Each visitor gets an independent session. No session data is shared.
- **OOB swap**: The session ID is injected using HTMX's out-of-band swap mechanism (`hx-swap-oob="true"`), which replaces the hidden input by ID without affecting the chat messages swap.
- **Conversation context**: ADK's session service stores the full message history, allowing Gemini to handle follow-up questions (e.g., "Tell me more about that company").
## 13. Security
### Input Sanitization
- **User messages**: HTML-escaped via `html.EscapeString()` before rendering in the response fragment. Prevents XSS through user input.
- **Agent responses**: Processed through `formatResponse()` which first escapes all HTML, then applies safe markdown-to-HTML conversion (bold, bullet lists, paragraphs). No raw HTML from the LLM reaches the browser.
### Privacy Protection
The agent instruction explicitly states: *"Never reveal personal contact details (email, phone) — point them to the contact form on the website."* This prevents the agent from disclosing contact information even if it exists in the CV data.
### Infrastructure Security
- The `/api/chat` endpoint inherits the site's full middleware chain: recovery, logging, security headers (CSP, HSTS, X-Frame-Options, etc.).
- The agent context uses a 30-second timeout (`context.WithTimeout`) to prevent runaway requests.
- The agent context is detached from the HTTP request context (`context.Background()`) to avoid cancellation if the client disconnects mid-processing.
- Sessions are ephemeral (in-memory only) and not accessible across visitors.
### Rate Limiting
Gemini 2.5 Flash free tier enforces 15 requests/minute at the API level. For additional protection, the endpoint benefits from the site's existing middleware chain.
## 14. Testing
The chat mascot has a comprehensive Playwright test suite at `tests/mjs/83-chat-mascot.test.mjs` with **46 test assertions** across 25 test groups.
Tests 21-25 (intelligence tests) require a valid `GOOGLE_API_KEY` and make real API calls to Gemini. They verify that the agent produces accurate, cross-referenced answers from the CV data.
Gemini 2.5 Flash free tier provides **15 requests/minute** with no credit card required. Each chat message consumes 1 request. For a personal CV site, this is more than sufficient.
If the free tier is exceeded, Gemini returns a rate limit error, which the handler catches and displays as a generic error message to the user.