- All tab buttons replaced with SVG icons (Feather icon style). Hover for tooltip labels.
- Tab bar is now horizontally scrollable — swipe/scroll to reach all tabs on any screen width.
- Voice toggle (mute/unmute) removed from its fixed overlay position in the top-left corner. Now lives in the input bar as the first button, beside the mic.
- Mic and Send buttons converted to icon-only SVGs.
Grendal // personal AI assistant
This is the living documentation for Grendal — your personal AI life assistant. It covers what everything does, how it works under the hood, and a full changelog of every feature that has been built.
WHAT IS GRENDAL
Grendal is a self-hosted AI assistant that lives on your own server, accessible from your phone and laptop via Tailscale. She is built on Claude (Anthropic's AI), has a 3D animated avatar that lip-syncs when she speaks, and remembers things about you over time.
Unlike cloud assistants, Grendal runs on your infrastructure. The conversation history, memory, and habits database are all on your own server — not in someone else's system.
TECH STACK
TAB SYSTEM
The tab bar runs across the top of the interface and is horizontally scrollable — swipe left/right if buttons are off-screen. Each tab is an icon button; hover for a tooltip label.
Chat (bubble icon)
The main conversation screen. Shows a greeting with today's date and weather at the top. Messages appear as a scrollable thread — your messages right-aligned, Grendal's replies left-aligned — like a text conversation. All exchanges from the Avatar tab also log here. The full history is stored in SQLite and sent to Claude as context on every message.
Memory (bookmark icon)
Shows long-term facts Grendal has extracted and stored about you. These are pulled from grendal.db → memories table. Entries are grouped by category (personal, health, work, goals, family, preferences, schedule, hobbies) and weighted by importance (1–3 dots).
How memories get stored: After every chat exchange, a fast Haiku call analyses the conversation and decides if anything is worth remembering. You can also trigger it explicitly by saying remember that [fact] or note that [fact] at the start of a message — those are stored immediately at importance 3.
The × button on any memory entry deletes it permanently from the database.
Routines (checklist icon)
Daily habit tracker. Habits reset at midnight (local time). Tap a habit to mark it done. A streak counter shows consecutive days completed. The morning briefing automatically reports how many habits you haven't done yet.
Habits are seeded once on first launch: Drink water, Morning stretches, Review priorities, Eat breakfast, Personal hygiene. (Habit CRUD UI is on the roadmap.)
Diag (pulse icon)
Live system diagnostics. Refreshes every 5 seconds while the tab is open, stops when you leave. Two sections:
- Client Device — pulled from your browser: CPU cores, RAM (approximate), screen resolution, network type and speed, battery level and charging status, motion sensor availability.
- Server — pulled from the Node.js
osmodule on the host: hostname, platform, CPU model, load averages (1/5/15 min), memory usage %, server uptime, Grendal process uptime.
Load average turns orange if it exceeds the core count. Memory turns orange above 85% usage.
Avatar (person icon) — mobile only
Hidden on desktop (the avatar lives in the left panel there). On mobile, this tab shows the full 3D avatar. The text input bar is hidden here — this is a voice-only screen. Tap SPEAK, ask your question, tap again to stop. The response slides up as a popup overlay with a 60-second drain timer and a REPLY button to respond immediately. The avatar lip-syncs to the audio. All Avatar tab exchanges are also logged to the Chat tab history.
Brief (zap icon)
Not a tab — a button that triggers the morning briefing immediately, regardless of time of day. See the Briefing section below.
Settings (gear icon)
Appearance settings. All changes are live and stored in localStorage on your device — each device can have its own theme. See the Settings section below.
INPUT BAR
The bar at the bottom of the screen. Left to right:
The input bar is hidden on the Avatar tab — that screen is voice-only by design.
AVATAR SYSTEM
Grendal uses the TalkingHead v1.7 library — an open-source 3D avatar engine built on Three.js. It loads GLB (3D model) files and animates them with blendshapes for facial expressions and lipsync.
How lip sync works
When Grendal speaks, the browser fetches an MP3 from the /api/tts endpoint (which proxies Google Translate's text-to-speech). That audio is then:
- Decoded at the browser's native sample rate (usually 44100 or 48000 Hz)
- Resampled to exactly 22050 Hz using
OfflineAudioContext— TalkingHead hardcodes this rate internally - Converted from 32-bit float to signed 16-bit integer PCM
- Passed to
head.speakAudio()along with estimated word timing
TalkingHead maps the audio to viseme (mouth shape) animations, driving the lip sync. If you skip the resampling step, the audio plays at 2× speed and the mouth moves at the wrong times — that was an early bug that is now fixed.
On the Avatar tab, lip sync always runs even if the voice toggle is off. On the Chat tab, lip sync only runs if the voice toggle is on.
Swapping avatars
Four color swatches appear at the bottom of the avatar panel (desktop). Click one to swap. Your choice persists in localStorage. Available avatars from the TalkingHead CDN:
The heavier avatars take a moment to load on first switch. The swatch border glows to show the active selection.
SETTINGS
All appearance settings use CSS custom properties (var(--accent), var(--bg), etc.) on the :root element. JavaScript reads your stored preferences from localStorage on load and sets these variables — every color, border, and font in the UI responds instantly.
Settings are per-device — your phone and laptop can have different themes. They are stored in localStorage and never sent to the server.
MEMORY SYSTEM
Grendal has two kinds of memory, stored in separate database tables:
Every message you send and every reply Grendal gives is logged here. The last 20 messages are included as context in every API call to Claude — this is how she follows a conversation thread. This table grows indefinitely (no cleanup yet).
Long-term facts extracted from your conversations. This is what appears in the Memory tab. Two ways entries are created:
- Automatic (Haiku extraction) — After every chat exchange, a fast background call to Claude Haiku analyses the user message + Grendal reply and decides if there's anything worth remembering. The prompt casts a wide net: preferences, plans, health, family, schedule, hobbies, opinions, things you like or dislike.
- Explicit (immediate storage) — Start your message with
remember that [fact],note that [fact], ordon't forget that [fact]and it is stored instantly at importance 3, bypassing Haiku entirely.
Duplicate check: if the exact same category + content combination already exists, it won't be added again.
Both tables are in ~/grendal/grendal.db on the server. You can inspect them directly:
ssh user@your-server "sqlite3 grendal/grendal.db \
'SELECT category, importance, content FROM memories ORDER BY importance DESC;'"
ROUTINES / HABITS
The Routines tab is a daily checklist. Each habit tracks a streak — consecutive days where you completed it. Streaks are computed by walking backwards from today in the completions table.
Habits reset at midnight local time (the completed_date column stores date strings in Pacific time via SQLite's date('now', 'localtime')). If the server clock drifts or the timezone config changes, streak counts may be affected.
Currently there is no UI to add, edit, or delete habits — that's on the roadmap. To modify habits directly:
ssh user@your-server "sqlite3 grendal/grendal.db \
\"INSERT INTO habits (name) VALUES ('Your habit here');\""
MORNING BRIEFING
The briefing is a spoken summary triggered either automatically (if the app loads between 4–9 AM) or manually via the zap icon. It reads:
- Current time and date (Pacific time)
- Temperature, weather condition, wind speed (Open-Meteo — coordinates set in .env)
- A randomly selected motivational line
- If Google Calendar is authenticated: today's event count and first event
- If Gmail is authenticated: recent important email summary
After the briefing, if it's morning mode and you have incomplete habits, Grendal announces the count and automatically switches to the Routines tab.
The briefing does not use Claude — it's a templated string built in server.js. This keeps it fast and free.
DIAGNOSTICS
The Diag tab pulls data from two sources simultaneously:
Client-side data is collected entirely in the browser using Web APIs — no server round-trip. navigator.hardwareConcurrency for cores, navigator.deviceMemory for RAM, navigator.getBattery() for battery, navigator.connection for network. These APIs have varying browser support; unavailable values show as ?.
Server-side data comes from the /api/diag endpoint, which uses Node's built-in os module — no extra packages. Load average is the Unix 1/5/15-minute average; on a single-core server anything above 1.0 in the 1-minute average is worth watching.
PUSH NOTIFICATIONS
Grendal can proactively reach out via push notification. The system has two parts:
Subscription: On first load, after a 3-second delay, the app requests notification permission. If granted, it registers a Web Push subscription (VAPID keys stored in .env) and saves the endpoint to the push_subscriptions table.
Heartbeat: Every 20 minutes, a background function runs on the server. It checks current time, how long since your last conversation, your incomplete habits, and calendar events — then asks Haiku whether it makes sense to send you a notification. Haiku can say no. The minimum gap between notifications is 1 hour. Notifications don't run between midnight and 4 AM.
If a push subscription becomes invalid (device changed, permission revoked), the server deletes it automatically on the next failed send (HTTP 410/404 response from the push service).
CHANGELOG
- Chat thread: Replaced single overwriting response div with a growing message thread. User messages appear right-aligned, Grendal replies left-aligned. Auto-scrolls to bottom on each exchange.
- Avatar tab popup: Responses on the Avatar tab now appear as a slide-up overlay with a 60-second drain timer and a REPLY mic button. Chat tab shows replies inline — no popup.
- All Avatar tab exchanges are logged to the Chat tab history regardless of which tab is active.
- TTS audio fix: Replaced
Audio.play()with a sharedAudioContextapproach.unlockAudio()is called synchronously on every user gesture (send, mic, speak button) before any async work — bypassing browser autoplay policy that was silently blocking audio. - Avatar tab hides the text input bar — voice-only interface.
forceAudioparameter ensures lip sync always runs on Avatar tab.
- Mobile no longer shows avatar as a fixed 90×100px head bubble in the top-right corner.
- Avatar panel hidden entirely on mobile. New AVATAR tab (person icon, mobile-only) shows the full avatar. On desktop the avatar panel remains unchanged.
positionAvatarForDevice()moves the#avatarContainerDOM element into the avatar tab mount on mobile before TalkingHead initializes.- Settings tab: RELOAD APP button unregisters the service worker (clearing PWA cache) then reloads. Useful after server restarts.
- New gear icon tab with live appearance settings: accent color, background color, font size (11–18px), border thickness (1–3px).
- Full CSS refactor: all hardcoded hex values replaced with
var(--accent),var(--bg),var(--font-size),var(--border-sz). Transparent variants usecolor-mix(in srgb, ...)for dynamic derivation. - Settings persist to localStorage per device. Reset button restores defaults.
- New DIAG tab showing live client device info and server stats.
- Client: cores, RAM, screen, network type/speed, battery, motion sensor availability.
- Server:
/api/diagendpoint using Nodeosmodule — hostname, CPU, load averages, memory, uptime. - Auto-refreshes every 5 seconds while tab is active; interval clears on tab switch.
- Color coding: load average and memory turn orange at warning thresholds.
- Four colored swatches at the bottom of the desktop avatar panel.
- Clicking a swatch calls
head.showAvatar()with a different GLB URL. - Available: brunette (default), brunette-t, avatarsdk, avaturn. mpfb.glb (36MB) excluded as too large.
- Active avatar persists to localStorage.
- Replaced old show/hide screen system with a proper tab system.
- Desktop: flex-row layout — 300px avatar panel on the left, scrollable content + tab bar on the right.
- Mobile (original): avatar as 90×100px fixed bubble top-right, main panel full-screen.
switchTab()replaces all oldshowMemory(),hideMemory(),showRoutines(), etc. functions.
- Bug: Explicit
rememberregex matched any sentence containing the word "remember" — storing fragments like "that." and "me telling you last". - Fix: Regex now only fires on
remember that [X]orremember: [X]patterns at the start of a message. - Haiku extraction prompt broadened from "long-term facts about themselves" to a wider net including preferences, opinions, hobbies, plans, schedule, health.
- Haiku
max_tokensraised from 150 → 200 to prevent JSON truncation.
- Replaced the canvas-based geometric avatar (animated shapes) with a full 3D character via TalkingHead v1.7.
- Avatar loaded from CDN as a GLB file. Runs in a Three.js WebGL context inside
#avatarContainer. - Lip sync bug: Audio was playing at 2× speed with visemes firing at wrong times. Root cause: browser
AudioContextdecodes MP3 at native rate (44100/48000 Hz) but TalkingHead expects exactly 22050 Hz PCM. Fixed by resampling throughOfflineAudioContextbefore passing tospeakAudio(). - Fixed
head.speakClear()(method doesn't exist) →head.stopSpeaking().
google-tts-apipackage depended on axios ≤0.30.2 (CSRF, SSRF, DoS CVEs). Removed entirely.- Replaced with a direct
fetch()call to Google Translate's unofficial TTS endpoint inserver.js. Zero new dependencies. - Text is chunked at word boundaries (max 180 chars per request) to stay within Google Translate's limit. Chunks fetched in parallel and concatenated.
- Bug: Server running UTC was reporting wrong time (7:30 PM when it was 10:55 AM Pacific).
- Fix: Added
localNow()helper that wrapsnew Date()usingtoLocaleString('en-US', { timeZone: 'America/Los_Angeles' }). All time-aware logic (mode detection, briefing, chat system prompt) now uses Pacific time. - Current local time injected into every chat system prompt so Claude always knows what time it is for the user.
DEPLOYMENT
Grendal runs on a remote server managed by pm2. After pushing to GitHub, deploy with:
ssh user@your-server "cd grendal && git pull && pm2 restart grendal"
pm2 is configured to start on system boot. To check status:
ssh user@your-server "pm2 status"
If the app feels stale on your phone after a deploy, use Settings → RELOAD APP to clear the PWA cache. The service worker can cache old JS/HTML aggressively.
ENVIRONMENT VARIABLES
Stored in ~/grendal/.env on the server (gitignored). Never committed.
To re-authenticate Google (Calendar + Gmail): visit /auth/reset on the live URL. This clears the stored tokens and redirects through the OAuth flow.