GRENDAL · DOCUMENTATION
← BACK TO APP

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.

Live URLconfigured via CLOUD_URL in .env
Local (Tailscale)configured in Tailscale dashboard
Serverself-hosted — your own machine or VPS
Process managerpm2 — survives reboots
DatabaseSQLite · ~/grendal/grendal.db (gitignored)

TECH STACK

RuntimeNode.js v24 + Express v5
AI@anthropic-ai/sdk · Sonnet 4.6 (chat) · Haiku 4.5 (memory extraction, heartbeat)
Databasebetter-sqlite3 (synchronous SQLite bindings)
AvatarTalkingHead v1.7 (Three.js, GLB avatars, lipsync)
TTSGoogle Translate TTS proxy (unofficial, no API key required)
Calendar / GmailGoogle APIs via OAuth2 (googleapis package)
Push notificationsweb-push (VAPID)
WeatherOpen-Meteo API (free, no key)
PWAService worker + web manifest (installable on home screen)

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.dbmemories 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 os module 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:

Speaker icon (muted/on)Voice toggle — controls whether Grendal speaks responses aloud. Off by default. Glows when on. On the Avatar tab, audio always plays regardless of this toggle. Persists across sessions via localStorage.
Mic iconVoice input — tap to start listening, tap again to stop. Uses the browser's SpeechRecognition API. Pulses green when active. On the Avatar tab, use the SPEAK button instead.
Text fieldType your message. Press Enter or tap Send to submit.
Send arrowSubmit the typed message.

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:

  1. Decoded at the browser's native sample rate (usually 44100 or 48000 Hz)
  2. Resampled to exactly 22050 Hz using OfflineAudioContext — TalkingHead hardcodes this rate internally
  3. Converted from 32-bit float to signed 16-bit integer PCM
  4. 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:

brunette.glb4.7 MB · default
brunette-t.glb2.9 MB · alt variant
avatarsdk.glb12.3 MB · Avatar SDK style
avaturn.glb13.8 MB · Avaturn style

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.

Accent ColorThe primary color used for all text, borders, glows, buttons, and icons. Default: #00e5ff (cyan).
BackgroundMain background color. Default: #040810 (near-black).
Font SizeAffects chat messages and the text input. Range: 11–18px. Default: 13px.
Border ThicknessControls all the subtle divider lines. Range: 1–3px. Default: 1px.
Reset DefaultsRestores all four settings to their defaults.
Reload AppUnregisters the service worker (clears PWA cache) then reloads. Use after a server restart if the app looks stale.

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:

conversations table

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).

memories table

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], or don'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

Tab bar icons + horizontal scroll · Voice toggle moved to input bar
UI
  • 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.
Chat thread · Avatar popup · AudioContext TTS fix
UIFEATUREFIX
  • 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 shared AudioContext approach. 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. forceAudio parameter ensures lip sync always runs on Avatar tab.
Mobile: avatar moved to its own tab · Settings reload button
UI
  • 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 #avatarContainer DOM 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.
Settings tab · CSS custom properties refactor
FEATUREUI
  • 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 use color-mix(in srgb, ...) for dynamic derivation.
  • Settings persist to localStorage per device. Reset button restores defaults.
Diagnostics tab
FEATURE
  • New DIAG tab showing live client device info and server stats.
  • Client: cores, RAM, screen, network type/speed, battery, motion sensor availability.
  • Server: /api/diag endpoint using Node os module — 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.
Avatar swatch picker · 4 TalkingHead avatars
FEATURE
  • 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.
Desktop / mobile layout redesign · Tab system
UI
  • 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 old showMemory(), hideMemory(), showRoutines(), etc. functions.
Memory extraction fix
FIX
  • Bug: Explicit remember regex 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] or remember: [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_tokens raised from 150 → 200 to prevent JSON truncation.
TalkingHead 3D avatar · Lip sync implementation
FEATUREFIX
  • 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 AudioContext decodes MP3 at native rate (44100/48000 Hz) but TalkingHead expects exactly 22050 Hz PCM. Fixed by resampling through OfflineAudioContext before passing to speakAudio().
  • Fixed head.speakClear() (method doesn't exist) → head.stopSpeaking().
TTS endpoint · Removed google-tts-api vulnerability
FIXINFRA
  • google-tts-api package 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 in server.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.
Timezone fix · Pacific time on UTC server
FIX
  • Bug: Server running UTC was reporting wrong time (7:30 PM when it was 10:55 AM Pacific).
  • Fix: Added localNow() helper that wraps new Date() using toLocaleString('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.

ANTHROPIC_API_KEYAnthropic API key for Claude calls
GOOGLE_CLIENT_IDOAuth2 client ID (Calendar + Gmail)
GOOGLE_CLIENT_SECRETOAuth2 client secret
VAPID_PUBLIC_KEYWeb Push public key
VAPID_PRIVATE_KEYWeb Push private key

To re-authenticate Google (Calendar + Gmail): visit /auth/reset on the live URL. This clears the stored tokens and redirects through the OAuth flow.