Family Assistant — Deployment Instructions
Architecture Overview
Email → Cloudflare → Worker JS → hook.yourdomain.com → FastAPI (localhost:5000) → pipeline → Radicale CalDAV (localhost:5232) → Phone sync
→ ChromaDB (Brain)
→ Hermes (Telegram)
1. Radicale CalDAV Server
Install & Configure
pip install radicale bcrypt
Create config at ~/.config/radicale/config:
[server]
hosts = 0.0.0.0:5232
[auth]
type = htpasswd
htpasswd_filename = ~/.config/radicale/htpasswd
htpasswd_encryption = bcrypt
[rights]
type = from_file
file = ~/.config/radicale/rights
[storage]
filesystem_folder = ~/.local/share/radicale/collections
Create htpasswd (use bcrypt — $2b$ prefix required, $apr1$ will fail):
htpasswd -B -c ~/.config/radicale/htpasswd assistant
# Add more users:
htpasswd -B ~/.config/radicale/htpasswd matt
htpasswd -B ~/.config/radicale/htpasswd aundrea
Create rights file at ~/.config/radicale/rights:
[all]
user: .+
collection: .*
permissions: RrWw
Install Systemd Service (requires sudo)
sudo ln -sf /path/to/family-assistant/systemd/radicale.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable radicale
sudo systemctl start radicale
Verify
curl -s -u assistant:YOUR_PASSWORD -o /dev/null -w "%{http_code}" http://127.0.0.1:5232/
# Should return 302
Create Calendar
import caldav
client = caldav.DAVClient(url="http://127.0.0.1:5232", username="assistant", password="YOUR_PASSWORD")
client.principal().make_calendar(name="family")
Phone Setup (via Cloudflare Tunnel)
Add CalDAV account on iPhone:
- Server: cal.yourdomain.com (Cloudflare Tunnel → localhost:5232)
- Username: assistant (shared account for family visibility)
- Password: your Radicale password
- Description: Family Calendar
Note: iOS requires HTTPS for CalDAV. Use a Cloudflare Tunnel or reverse proxy with TLS.
iOS Discovery: iOS only auto-discovers calendars under the logged-in user's principal. Both family members should use the
assistantaccount for shared visibility.
2. Cloudflare Tunnel
Set up two tunnels (or two public hostnames on the same tunnel):
hook.yourdomain.com→localhost:5000(webhook)cal.yourdomain.com→localhost:5232(Radicale)
In the Radicale config, add the header:
[headers]
X-Forwarded-Proto = https
This tells Radicale to generate proper HTTPS URLs in CalDAV responses.
3. Webhook Server (FastAPI)
Install Systemd Service (requires sudo)
sudo ln -sf /path/to/family-assistant/systemd/hoffdesk-webhook.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable hoffdesk-webhook.service
sudo systemctl start hoffdesk-webhook.service
Verify
curl http://127.0.0.1:5000/health
# {"status":"healthy"}
4. Cloudflare Email Worker
- Go to Cloudflare Dashboard → Workers & Pages → Create Worker
- Name it
email-assistant - Paste contents of
scripts/email_worker.js - Settings → Variables and Secrets → Add:
-WEBHOOK_SECRET: (generate withopenssl rand -base64 48, matchWEBHOOK_SECRETin.env) - Deploy
Configure Email Routing
- Cloudflare Dashboard → select your domain → Email → Email Routing
- Add rule:
assistant@yourdomain.com→ Send to Worker - Select the
email-assistantworker - Save
Important: The worker code hardcodes the webhook URL (
https://hook.yourdomain.com/webhook). OnlyWEBHOOK_SECRETneeds to be set as an env var.
5. Environment Variables
All secrets in scripts/.env (chmod 600, gitignored):
# CalDAV
CALDAV_URL=http://127.0.0.1:5232
CALDAV_USER=assistant
CALDAV_PASSWORD=your-radicale-password
CALDAV_CALENDAR_NAME=family
# Webhook
WEBHOOK_SECRET=your-webhook-secret-here
# LLM
LLM_URL=http://localhost:11434/v1/chat/completions
LLM_MODEL=qwen2.5-coder:7b
# Telegram
TELEGRAM_BOT_TOKEN=your-bot-token
TELEGRAM_CHAT_ID=your-group-chat-id
TELEGRAM_DEV_ID=your-dm-chat-id
# Optional
OLLAMA_EMBED_URL=http://localhost:11434/api/embeddings
VISION_LLM_URL=http://localhost:11434/api/chat
GOOGLE_PLACES_API_KEY=your-key # Optional, Nominatim is free fallback
6. Backup
# Manual backup
./scripts/backup_hoffdesk.sh
# Skip remote copy (e.g., Gaming PC offline)
SKIP_REMOTE=true ./scripts/backup_hoffdesk.sh
# Runs daily at 7 AM via cron
Backs up: Radicale collections, ChromaDB, location cache, .env files, Radicale config.
Retention: 7 days local, 7 days remote (via Tailscale SSH).
Set REMOTE_HOST in the script or .env to enable remote backup:
REMOTE_HOST=user@remote-host.tail-xxxxx.ts.net
End-to-End Test
# 1. Verify Radicale
python3 -c "from family_assistant.calendar_sync import list_upcoming_events; print(list_upcoming_events())"
# 2. Test webhook pipeline
python3 -c "
from family_assistant.pipeline import process_webhook_email
result = process_webhook_email({
'from': 'test@test.com', 'to': 'assistant@yourdomain.com',
'subject': 'Test Appt', 'date': '2026-04-20T10:00:00-05:00',
'body_text': 'Test on April 21 2026 at 3pm at Home',
'dedup_key': 'e2e-test', 'message_id': 'e2e-test', 'source': 'webhook'
}, dry_run=True, notify=False)
print(result)
"
# 3. Send real email to assistant@yourdomain.com → verify event appears in Radicale
Troubleshooting
Radicale 401/403: Check htpasswd uses bcrypt ($2b$ prefix), not apr1 ($apr1$). This is the most common Radicale auth failure.
Radicale EADDRNOTAVAIL: Tailscale IPs are virtual interfaces that may rotate. Bind to 0.0.0.0:5232 instead of a specific Tailscale IP.
Webhook 401: Verify X-Hoffdesk-Secret header matches WEBHOOK_SECRET in .env. The worker sends the env var value; the webhook checks the same value from .env.
Calendar not found: CalDAV calendar is auto-discovered by name under the authenticated user. If missing, recreate it manually (see step 1).
iOS CalDAV: iOS requires HTTPS. Use a Cloudflare Tunnel. Both family members should log in as the assistant user for shared calendar visibility — iOS auto-discovery is per-user only.
Webhook not receiving email: Check Cloudflare Email Routing is configured for assistant@yourdomain.com → Worker. Check the worker logs in Cloudflare Dashboard → Workers → Logs.
Location resolution failing: If Google Places API key is missing or suspended, the pipeline falls back to Nominatim (free, no API key). Check location_cache.py logs for resolution attempts.
Day-of-week mismatch warnings: This is intentional — if an email says "Monday the 21st" but the 21st is actually Tuesday, the pipeline adds a ⚠️ to the notification. Check the email source for errors.