No matching sections. Try “voice”, “calendar”, or “install”.
FRIDAY documentation.
From setup to internals.
The technical guide to FRIDAY Desktop and Sentinel: installation, architecture, runtime behavior, tools, integrations, configuration, and operations.
Desktop
Real-time voice, visual widgets, interactive 3D, and native macOS automation.
THE ALWAYS-ON COMPANION ↗Sentinel
Monitoring, triage, alerts, meeting minutes, and conversations across your devices.
FRIDAY is one Python package with three parts: friday.core holds shared settings, storage, the secrets vault, and the Gemini Live engine. friday.desktop is the Mac node. friday.sentinel is the headless daemon, running on Linux, Raspberry Pi OS, or macOS.
Each mode can run independently. When connected, Desktop sends heartbeats and telemetry to Sentinel, receives alerts, and pulls its model configuration at boot. A last-good local cache lets it continue if Sentinel is unreachable.
master branch does not include this mode.
Choose your starting point.
Both modes need Python 3.11+ and a Google Gemini API key for AI features. Desktop requires macOS 12+ and audio/GUI dependencies. Sentinel installs without Mac-specific dependencies.
1. Prepare your checkout
From a checkout containing Sentinel, create a virtual environment. If cloning the project, use the repository URL from your source access and select the Sentinel-enabled branch.
python3 -m venv .venv
cp .env.template .env
Copy the template only on first setup; keep an existing .env if you already configured FRIDAY.
2A. Start Desktop
.venv/bin/pip install -e ".[desktop]"
For a standalone Desktop, set GEMINI_API_KEY in your .env. Then launch:
.venv/bin/python -m friday.desktop
Grant the permissions needed for the features you use in System Settings → Privacy & Security: Microphone and Camera, Screen Recording, Accessibility, Calendars, and Automation. Desktop uses PyAudio and PyWebView; install the platform prerequisites required by those packages if their installation fails.
To serve the desktop UI in a browser instead, run .venv/bin/python -m friday.desktop.hub and open http://127.0.0.1:8766. The desktop WebSocket gateway uses port 8765.
2B. Start Sentinel
.venv/bin/pip install -e ".[sentinel]"
# First setup only. Keep a secure backup of this key.
.venv/bin/python -m friday.sentinel keygen >> .env
.venv/bin/python -m friday.sentinel user set-password admin
# Start the daemon
.venv/bin/python -m friday.sentinel
FRIDAY_MASTER_KEY makes existing vault secrets unreadable. Do not rerun key generation during upgrades.
Open http://127.0.0.1:8770/, sign in with the user you created, and enter your Gemini API key under Settings → Model & Core. Sources and channels need their own setup; monitors, triage, and the WhatsApp control ship off.
3. Connect Desktop to Sentinel
On the Sentinel host, create a token. Its plaintext is shown once:
.venv/bin/python -m friday.sentinel token create desktop
On the Mac, set these in .env. Use the loopback URL only when both modes run on the same Mac; for separate machines, use the private HTTPS URL from the deployment guide.
FRIDAY_SENTINEL_URL=http://127.0.0.1:8770
FRIDAY_SENTINEL_TOKEN=fn_replace_with_your_node_token
Restart Desktop to pull its scoped model configuration. Secrets come from the vault when present; legacy .env values are still used when the vault has no value. Process environment overrides .env.
Watching is only the beginning.
Sentinel normalises incoming items, persists them in SQLite, and publishes events to its bus. Rules handle clear urgency signals; Gemini triage handles the remaining items. A policy engine then chooses a call, alert, digest, or no interruption.
Connect your sources
| Source | Setup | Boundary |
|---|---|---|
| Work mail | Google OAuth / Gmail API | Read and draft; never send |
| Personal mail | IMAP host, user, app password | Read and append drafts; no SMTP |
| Calendar | Google OAuth / Calendar API | Read and create; modify only FRIDAY-created events |
| Jira | Base URL, email, API token, JQL | GET-only connector |
| Google Meet | Drive API + additional OAuth consent | Read transcript documents you own |
Enter credentials in Settings → Sources & Monitors. For Google, create a Desktop app OAuth client and configure its ID and secret before authorising:
.venv/bin/python -m friday.sentinel google-auth
On a headless host, run authorisation on a machine with a browser using --print-only, then paste the token into Settings. Enable the required Google APIs in the same Cloud project. Switch on each source under Controls.
Decide what deserves attention
Switch on Controls → Triage after configuring sources. Settings under Alert Escalation & Channels control VIP senders, muted senders, critical keywords, quiet hours, digest times, suggested drafts, and model budget. Set sentinel.timezone so schedules use your local time.
- Rules first: VIPs, Jira priority, imminent meetings, and critical keywords can produce deterministic verdicts.
- Model second: remaining items get a validated structured verdict. Fallback verdicts cannot be critical.
- Policy last: urgency is gated by call mode, do not disturb, quiet hours, and the meeting guard. Suppressions are recorded too.
- Drafts are opt-in:
policy.draft_repliescan prepare suggested replies for urgent mail. It cannot send them.
Your dashboard
| Page | What you can do |
|---|---|
| Overview | Check source states, WhatsApp queue health, node heartbeats, and telemetry. |
| Activity | Inspect the live event stream; filter events by type, source, or text. |
| Triage | See judged items, urgency, decisions, and suppressions. |
| Meetings | Upload recordings, check the pipeline, and retry failed meetings. |
| Controls | Change live switches instantly. Refused changes revert and show a reason. |
| Assistant | Chat with streamed markdown replies, or start a Gemini Live voice session. |
| Settings | Edit vault settings across five domain cards. Save changes explicitly or discard. |
| Nodes & tokens | Issue and revoke node tokens. |
A shared assistant
Dashboard text, browser voice, and authorised WhatsApp conversations can inspect nodes, telemetry, recent events, settings, triage, and the digest. Ask “what did I miss?”, create a calendar reminder, or change operational controls. Control changes are audited with the channel that made them.
Browser voice
Start voice opens a Gemini Live session through Sentinel. Mic audio streams at 16 kHz; playback returns at 24 kHz. Transcripts join the same conversation as text. Mute mic leaves the session connected; End, leaving the page, or closing the tab ends it.
Use ⌘K or / to focus the message input, and ⌘/ to start or end voice. Set voice.enabled and voice.name in Settings → Telephony & Voice Agent. Browser mic access requires localhost or a secure HTTPS context.
Speech for intent. Screen for substance.
Desktop combines Gemini Live voice, a reactive Three.js orb, an asynchronous widget deck, and a persistent spatial workspace. Background agents handle multi-step work while the voice session remains available for interruption.
The orb is the status display
| State | Colour | Meaning |
|---|---|---|
| Idle | Cyan | Connected and waiting |
| Listening | Blue | Your voice is coming in |
| Thinking | Amber | A tool or background agent is working |
| Speaking | Emerald | FRIDAY is talking |
| Offline | Ember | The hub is unreachable |
Colour stays tied to state. Motion follows audio playback. Tap the orb to interrupt. With no widgets, the orb sits centrally; as content arrives, it moves into the left rail.
Cards and 3D
The Cards tab holds generated HTML widgets: visual summaries, metric grids, charts, and feeds. A skeleton appears immediately and a background generator fills it. The hub manages up to eight cards and sanitises generated markup before display.
The 3D tab holds persistent editable SVE scenes and generated models. One surface is visible at a time; hidden ones retain their camera state while pausing rendering. Ask FRIDAY to show a scene or model by name.
- Spatial Visualization Engine: build and edit objects, labels, and scenes with incremental updates. Suitable for structural and editable visuals.
- Generated assets: Tripo turns a description into a textured GLB. Requires a separate
TRIPO_API_KEYand generation credits. Saved models can be shown again without regenerating. - Hand gestures: local MediaPipe tracking supports pointing, pinch-dragging, orbiting, and two-hand zoom. The active 3D surface receives gestures.
Native Mac agency
FRIDAY uses Quartz for multi-monitor capture, Accessibility for UI inspection, and CGEvent for mouse and keyboard input. It can execute shell and AppleScript commands, work with EventKit calendars and Apple Mail, and delegate multi-step operations to background agents.
Face recognition uses local YuNet and SFace models. Voice profiling uses local MFCC features. These local biometric paths are distinct from the cloud audio and image processing used by Gemini.
The conversation goes with you.
Connect WhatsApp
The Go sidecar pairs as a linked device. The Sentinel writes outbound messages to a durable SQLite outbox, then sends at your configured pace. Only whatsapp.to can receive messages or reach the inbound assistant; groups, self-messages, and unauthorised numbers are dropped.
# Requires Go on the build machine
deploy/build_sidecar.sh
# Pair on the sending host while the bridge is switched off
.venv/bin/python -m friday.sentinel whatsapp-pair
Scan the QR code in WhatsApp → Linked devices. On a separate host, copy the matching built binary to deploy/bin/friday-whatsapp as described in the repository’s deployment guide. Set your destination under Settings → Alert Escalation & Channels, then add the bridge to the host’s .env:
FRIDAY_BRIDGES=friday.sentinel.bridges.whatsapp.WhatsAppBridge
Restart Sentinel after changing FRIDAY_BRIDGES. Enable Controls → WhatsApp. Enable whatsapp.inbound for conversations and alert replies. Conversations are grouped by local day and visible in the dashboard Assistant.
Respond to an alert
Alerts include a short code, such as #3. With inbound messaging enabled, send an exact command, quote the alert, or include its code when several alerts are open.
| Reply | Result |
|---|---|
#3 ack | Mark handled and stop further reminders. |
#3 snooze 2h | Raise it again later. Also supports 90m, 1d, tomorrow, or a bare snooze for 1 hour. |
#3 not urgent | Close the alert, mark normal, and record feedback. |
#3 dismiss | Close it without further action. |
A bare command works when exactly one alert is open. With several, FRIDAY asks which one instead of guessing. Unanswered critical alerts repeat every policy.realert_min minutes (default 30), up to policy.realert_max repeats (default 2). Alerts expire after policy.alert_ttl_h hours (default 24). DND holds reminders.
Optional phone call agent
Phone calls require a secondary Android phone, wireless adb, and an audio connection from its USB-C adapter to a USB sound card on the Linux host. ALSA’s arecord and aplay carry the audio.
sudo apt install android-tools-adb alsa-utils
arecord -l
aplay -l
# Replace with your phone's wireless-debugging addresses
adb pair PHONE_IP:PAIRING_PORT
adb connect PHONE_IP:DEBUG_PORT
Set telephony.adb_serial, telephony.audio.capture, telephony.audio.playback, and optionally telephony.call_to in Settings. Add friday.sentinel.telephony.bridge.TelephonyBridge to the comma-separated FRIDAY_BRIDGES list, restart, and enable Controls → Telephony.
Dial, answer, and hang-up commands depend on the Android ROM. A phone that refuses permissions cannot be assumed to work; editable command templates are under Advanced Hardware Templates. Failed calls fall back to WhatsApp with a reason, when that channel is configured.
Incoming calls from your number are read only because caller ID can be spoofed. Other callers reach a message-taking agent. Outgoing alert calls to the configured owner number support acknowledge and snooze. Answered calls feed the meeting-minutes pipeline.
The meeting ends. The useful part stays.
FRIDAY turns audio recordings and transcripts into decisions and action items, delivered through WhatsApp with a meeting code such as #M1. The persisted pipeline resumes across restarts: received → transcribed → ready → delivered.
Four ways to add a meeting
- CLI: add a file on the Sentinel host.
- Dashboard: upload on the Meetings page, optionally set a title and time, and retry failed items.
- WhatsApp: send an audio or transcript document from your authorised number. Short uncaptioned voice notes are treated as questions instead.
- Google Meet: enable Meet transcription and let the monitor read new transcript documents you own in Drive.
.venv/bin/python -m friday.sentinel minutes add \
~/Downloads/launch-sync.m4a --title "Launch sync"
.venv/bin/python -m friday.sentinel minutes add \
meet-transcript.vtt --date 2026-10-02T10:00
A date without an offset uses sentinel.timezone. Audio supports M4A, MP3, WAV, OGG, OPUS, FLAC, AAC, and AIFF. Transcripts support VTT, SRT, and TXT. Dashboard uploads default to 200 MB; WhatsApp media defaults to 50 MB. Uncaptioned voice notes up to 120 seconds are answered as questions by default.
Get the next steps
| Reply | Result |
|---|---|
#M1 tasks | Numbered action items with owners and dates. |
#M1 decisions | The meeting’s decisions. |
#M1 transcript | The transcript, split into parts. |
#M1 add 3 | Add action item 3 to the calendar. |
| Quote the minutes + a question | Ask the assistant with that meeting as context. |
Set minutes.my_names to the names people use for you. Your action items with future dates can be added to your calendar automatically; disable this with minutes.auto_calendar. Recordings are deleted after transcription. Transcripts and minutes default to 180-day retention; meeting codes are reusable after 7 days.
Enable Google Meet ingestion
Enable the Google Drive API in your existing Google Cloud project, rerun google-auth for Drive read access, and switch on Controls → Google Meet. Only transcript documents you own are ingested. Use uploads for meetings organised by others. The first poll looks back one day.
Give Sentinel a place to stay.
Sentinel defaults to 127.0.0.1:8770. State is stored under data/ or FRIDAY_DATA_DIR. The dashboard ships as static assets, so no frontend build is needed on a Raspberry Pi.
Run as a service
The repository includes deploy/systemd/friday-sentinel.service for Linux and deploy/launchd/com.friday.sentinel.plist for macOS development. Substitute the repository and user placeholders before installing. The systemd unit uses a readiness notification and a 90-second watchdog.
sed -e "s|__USER__|$USER|g" -e "s|__REPO__|$PWD|g" \
deploy/systemd/friday-sentinel.service | \
sudo tee /etc/systemd/system/friday-sentinel.service
sudo systemctl daemon-reload
sudo systemctl enable --now friday-sentinel
journalctl -u friday-sentinel -f
macOS development service
Use the provided launchd template for a Sentinel running on your Mac:
mkdir -p data/logs ~/Library/LaunchAgents
sed "s|__REPO__|$PWD|g" deploy/launchd/com.friday.sentinel.plist > \
~/Library/LaunchAgents/com.friday.sentinel.plist
launchctl bootstrap gui/$(id -u) \
~/Library/LaunchAgents/com.friday.sentinel.plist
tail -f data/logs/sentinel.log
Private remote access
Use Tailscale Serve on the Sentinel host to expose the loopback service through your private tailnet:
tailscale serve --bg --https=443 --set-path=/sentinel \
http://127.0.0.1:8770
Behind Tailscale Serve, Caddy, or Nginx, set FRIDAY_TRUSTED_PROXY=true on the Sentinel host so forwarded HTTPS produces Secure session cookies. Restart after changing host bootstrap settings. Proxy the /ws and /voice/ws WebSockets too. Set Desktop’s FRIDAY_SENTINEL_URL to your actual private HTTPS address, including the /sentinel prefix when used.
Durable by design
POST /events commits events before returning 202. SQLite uses WAL with FULL synchronisation by default. Work interrupted while processing is requeued at boot. Event delivery is at least once with three handler attempts; handlers use idempotent operations. WhatsApp delivery has its own durable, paced outbox.
Background tasks restart with backoff. SIGINT and SIGTERM trigger an ordered shutdown bounded to ten seconds. Telemetry probes degrade gracefully when a sensor is unavailable.
API essentials
Node tokens use Authorization: Bearer fn_…. Dashboard routes use a signed-in session. State-changing dashboard calls also require X-FRIDAY-Client: dashboard.
| Endpoint | Purpose | Auth |
|---|---|---|
GET /health | Status and queue depths | None |
GET /nodes | Last heartbeat per node | Token or session |
GET /telemetry | Latest local telemetry | Token or session |
POST /events | Publish one event or a batch | Token or session |
GET /ws | Live events, with subscription filters | Token or session |
GET /config?scope=desktop | Scoped node configuration | Token or session |
GET/PUT /api/settings | Vault-backed settings | Session |
GET /api/watch | Source states and recent items | Session |
GET /api/triage | Judged items and urgency counts | Session |
GET /api/digest/preview | Preview the next digest | Session |
GET /api/whatsapp | Bridge and outbox status | Session |
GET/POST /api/chat | Assistant conversations | Session |
POST /api/chat/{id}/messages | NDJSON streaming text turn | Session |
GET /voice/ws | Gemini Live voice session | Token or session |
Know what you are giving access to.
FRIDAY is self-hosted, but its AI features are cloud-powered. Source data used for triage, conversations, visual perception, and audio transcription may be sent to Gemini. Optional model generation sends prompts to Tripo. Local biometrics and gesture tracking stay on the device.
Secrets and identity
- Vault: secrets in Sentinel’s SQLite database are AES-GCM encrypted with a key derived from
FRIDAY_MASTER_KEY. The setting name is authenticated with its ciphertext. - Dashboard: a single-user login with hashed session storage and HttpOnly, SameSite=Lax cookies; Secure when served through correctly configured HTTPS.
- Node access: issue and revoke tokens from the dashboard. Token plaintext is shown once. Configuration pulls are scoped and audited.
- Desktop cache: the last-good decrypted configuration is stored locally in
data/config-cache.jsonwith mode 0600. Protect the Mac and its backups accordingly. - Audit: sign-ins, settings changes, token events, and assistant control changes are recorded.
Source capabilities
Sentinel connectors enforce fixed capabilities in code: mail can read and draft but cannot send; Jira has no write method; calendar edits are restricted to events FRIDAY created. These limits describe the Sentinel connectors. Desktop’s native automation is a separate, broader capability.
Channels and caller identity
WhatsApp authorises the configured sender before dispatching assistant turns or downloading media, and deduplicates message IDs. It never allows an event payload to redirect the destination. Incoming phone calls are read only for the owner because caller ID is not a reliable authority to change state.
Operational checklist
- Back up
FRIDAY_MASTER_KEYsecurely along with the persistent data you need. - Keep local service bindings behind your intended private network or authenticated proxy.
- Enable only the sources and channels you intend to use.
- Review quiet hours, timezone, outbound pacing, and retention before relying on alerts.
- Verify the phone’s actual dial/answer behaviour before relying on telephony.
Package architecture & configuration authority
Source: readme.md; friday/core/config.py; friday/desktop/config_pull.py
FRIDAY is one installable package, friday, split into three sub-packages with a hard boundary between them:
friday.core— everything every node shares: settings (.env+ environment, per-role LLM routing), the SQLite/WAL store, event schemas, the AES-GCM secrets vault, the cross-platform telemetry collector and the Gemini provider. Imports only stdlib +psutil,aiohttp,python-dotenv,google-genai,cryptography. A test (tests/test_boundaries.py) imports every module in a poisoned interpreter and fails if anything macOS-only leaks in.friday.desktop— the macOS node: the Gemini Live hub, the HUD, audio, Quartz vision, CGEvent automation, EventKit, biometrics. Unchanged in behaviour; it now reads paths and model ids fromcoreand heartbeats to the sentinel whenFRIDAY_SENTINEL_URLis set.friday.sentinel— the headless 24/7 daemon. Identical code on macOS (launchd), Fedora and Raspberry Pi OS (systemd,Type=notifywith a watchdog).
The sentinel is the configuration authority. .env holds host bootstrap only (FRIDAY_MASTER_KEY, bind address, data dir); everything else — API keys, per-role model routes, Jira/Google/telephony credentials — lives in data/sentinel.db, AES-GCM-encrypted under a key derived from FRIDAY_MASTER_KEY, with the setting key bound as associated data so a ciphertext cannot be moved between rows. A single dashboard user (created with friday-sentinel user set-password) signs in at /; sessions are HttpOnly/SameSite=Lax cookies stored hashed; every login, settings change, token issue and config pull is audited. Nodes authenticate with vault-managed tokens (friday-sentinel token create <name>, shown once). The desktop pulls its LLM configuration from GET /config?scope=desktop at boot, caches the last good copy at data/config-cache.json (mode 0600) and falls back to its .env when the sentinel is unreachable. Legacy .env values are still honoured when the vault has none and show with an env badge in the dashboard. The dashboard (Overview, Activity, Triage, Meetings, Controls, Assistant, Settings, Nodes & tokens) is vanilla ES modules with a committed Tailwind build — nothing compiles on the Pi. It is described in full under The dashboard.
┌─────────────────────────────────────────────────────────┐
│ USER INTERFACES │
│ • Web GUI (Orb / Widget Deck / Spatial 3D / Gestures) │
│ • PyWebView desktop shell • Phone via Tailscale HTTPS │
│ • Voice In / Out (16 kHz PCM Mic / 24 kHz Speaker) │
└───────────────▲─────────────────────────▲───────────────┘
│ (WebSocket / Audio) │ (Touch / Video)
▼ ▼
┌───────────────────────────────────────────────────────────────────────────────────────────┐
│ DESKTOP NODE HUB (friday/desktop/hub.py) │
│ │
│ ┌───────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐ │
│ │ Audio Pipeline │ │ State & Event Hub │ │ Gemini Live Session │ │
│ │ • PyAudio 16kHz/24kHz │ │ • Play Queue / Interrupts │ │ • Aoede voice, tools │ │
│ │ • Voice Enrollment Buffer │ │ • Widget Deck Registry │ │ • Per-turn receive loop│ │
│ │ • Remote Audio Routing │ │ • Approval State Machine │ │ • In-run resumption │ │
│ └───────────────────────────┘ └───────────────────────────┘ └────────────────────────┘ │
└──────┬────────────────────┬───────────────────┬────────────────────────┬──────────────────┘
│ Tool Calls │ Dispatch │ Scene Ops │ Biometrics
▼ ▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ CAPABILITY │ │ BACKGROUND AGENTS│ │ SPATIAL ENGINE (SVE) │ │ RECOGNITION & MEMORY │
│ SUBSYSTEMS │ │ agents.py │ │ │ │ │
│ │ │ │ │ • sentry_scene.py │ │ • sentry_recognition │
│ • sentry_vision │ │ • os tier │ │ SceneGraph deltas │ │ YuNet + SFace 128-d│
│ • sentry_action │ │ (routed model) │ │ • web_gui/sve.js │ │ Mel MFCC voice │
│ • sentry_exec │ │ • spatial tier │ │ Three.js renderer │ │ • friday_memory.json │
│ • sentry_personal│ │ • widget writer │ │ • web_gui/gestures.js│ │ Persistent facts │
│ • sentry_web │ │ (routed model) │ │ MediaPipe hands │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────────┘ └──────────────────────┘Desktop interface & state transitions
Source: readme.md; friday/desktop/web_gui/
Workspace tabs
The workspace is split in two, behind a tab bar carrying live counts:
- Cards — HTML and component widgets, stacked newest-on-top and scrollable, exactly as before.
- 3D — every 3D surface, whether an SVE scene (
3d_spatial) or a generated Tripo asset (3d_asset). Only one is visible at a time, filling the full workspace height. The rest stay mounted with their WebGL context and camera intact but stop rendering, so nothing is re-loaded when you switch back.
Switch by clicking a pill above the stage, or ask FRIDAY — show_3d_view takes 'spatial' for the scene or a model's name, and is the only thing that moves the selection. A newly arrived model claims the slot and raises the 3D tab, the same way a new card raises Cards. Re-showing a saved model reuses its card (ids are derived from the file), so asking for the same one twice never stacks a second pill.
Hand tracking follows the visible model: switching to the scene hands gestures back to the SVE, switching to a model points them at that model.
Both 3D types share one chrome: the pills name them, so neither card carries its own header, and both use the same floating Hands / Reset view pills over a full-height stage.
The GUI is a spatial workspace with Cards and 3D tabs. The ambient orb, telemetry, agent chips, camera preview, command lane, and sensor dock occupy reserved regions around the workspace.
┌──────────────────────────────────────────────────────────────────────────┐
│ 21:04:18 │ CPU 38% │ RAM 12.8/16 GB ← ambient telemetry HUD │
│ ◐ OS agent · Open Safari and summarise… ← background agent chips │
│ │
│ ╭─────────╮ ┌──────────────────────────────────────────────┐ │
│ │ ORB │ │ ALPHABET INC. × │ │
│ │ (state) │ │ 207.42 USD ▲ +3.81 +1.87% │ │
│ ╰─────────╯ │ ╭────────────────────────────────────────╮ │ │
│ │ │ gradient area chart, dashed baseline │ │ │
│ ← 260px rail │ ╰────────────────────────────────────────╯ │ │
│ │ OPEN 204.10 HIGH 209.94 LOW 203.44 │ │
│ └──────────────────────────────────────────────┘ │
│ ┌───────────┐ ┌──────────────────────────────────────────────┐ │
│ │ camera PIP│ │ MARKET INTELLIGENCE × │ │
│ │ (mirrored)│ │ 01 [ALERT] Antitrust ruling lands Tuesday │ │
│ └───────────┘ └──────────────────────────────────────────────┘ │
│ ╭ Speak, or type… ╮ ╭─ 🎙 📷 🖥 ─╮ │
└──────────────────────────────────────────────────────────────────────────┘Layout states
Driven purely by how many widgets are mounted:
| Widgets | Layout |
|---|---|
| 0 | Orb dead-centre at full scale; workspace collapsed (opacity: 0, no pointer events) |
| ≥ 1 | Orb glides to the 260 px left rail at ~38 % scale; deck fills the right, newest card on top |
Transitions interpolate over 0.6s cubic-bezier(0.16, 1, 0.3, 1), and both WebGL renderers are re-fitted during the animation so nothing stretches.
The orb as status
State drives colour; colour holds until the state changes. Nothing else can shift the hue — audio drives motion and brightness only.
| State | Colour | Meaning |
|---|---|---|
| Idle | #00F2FE calm cyan | Connected, waiting |
| Listening | #0077FF deep blue | Your voice is coming in |
| Thinking | #FFB800 amber | Tool running or agent working |
| Speaking | #00FF88 emerald | FRIDAY is talking |
| Offline | #E5726F ember | Hub unreachable |
Over all five, a fixed violet-to-blush accent (#A18CD1 → #FBC2EB) tints the rim highlight and the outer bloom. It carries no state — it is purely FRIDAY's finish, and the state hue stays exactly as readable as before.
The "speaking" state follows the hub's authoritative turn status and the playback timeline — audio arrives roughly 3× faster than it plays, so the orb animates in step with what you hear, not with what has downloaded. Tap the orb to interrupt.
Ambient furniture
- Telemetry HUD (top-left) — live clock, CPU and RAM, updated every 2 s from the hub.
- Agent chips — one per running background agent, with a spinner and its goal.
- Camera PIP (bottom-left) — mirrored; the hand-landmark overlay is mirrored with it so the skeleton stays registered.
- Command lane (bottom-centre) — ghosted until you hover, focus, or simply start typing.
- Sensor dock (bottom-right) — three icon toggles: mic, camera, screen. Active glows cyan; inactive is ghosted with a strike.
Desktop engines, agents & visual generation
Source: readme.md; friday/desktop/hub.py; agents.py; widget_generator.py; asset_generator.py
The model identifiers and timing measurements below describe this checkout, not guaranteed provider availability or performance. Configure model routes for the API access available to your installation.
1. Gemini Live Hub (friday/desktop/hub.py)
- Bidirectional voice over WebSockets to
gemini-3.1-flash-live-preview, typedLiveConnectConfigwith the Aoede prebuilt voice (override withFRIDAY_VOICE). - Per-turn receive loop —
session.receive()yields one conversational turn and ends, so the loop re-enters it. Without that the session tears down after every reply and the reconnect replays the last turn. - Session lifecycle — the resumption handle is held in memory only, bridging GoAway rotations and drops within a run. A new process is always a new conversation.
- Persona —
FRIDAY_SYSTEM_INSTRUCTIONholds who she is and how she sounds: perceptive, effortlessly competent, dryly witty, subtly warm. It is a standalone constant, composed with the operational rules bybuild_system_instruction(), so the voice can be retuned without touching the tool, widget and safety instructions. - Frontend assets are never cached stale — the GUI server sends
Cache-Control: no-cache, must-revalidate. Without it WKWebView applies heuristic freshness and can keep rendering an oldapp.js/style.cssacross relaunches (the desktop shell runsprivate_mode=False, so its store survives restarts). ETags are kept, so unchanged files still cost only a 304. - Concise by default — spoken replies stay under ten words; when a widget is mounted, one or two sentences carrying the key takeaway. Detail belongs on screen.
2. Background Agent Tiers (friday/desktop/agents.py)
Live must stay free for barge-in, so anything multi-step is dispatched off the audio path via dispatch_agent. FRIDAY acknowledges in the same turn ("Working on that now.") and announces the result when it lands.
| Tier | Model (env var, default) | Handles |
|---|---|---|
os | FRIDAY_LLM_AGENT_OS, gemini-3.8-flash | macOS automation, AppleScript/shell chains, GUI operation |
spatial | FRIDAY_LLM_AGENT_SPATIAL, gemini-3.8-flash | Building and editing 3D SVE scenes |
| widget generator | FRIDAY_LLM_WIDGET, gemini-3.7-flash | Writing card HTML (see below) |
Agents reuse the hub's own tool implementations, run up to 12 tool round-trips, and report back a single spoken sentence. Results are queued for a gap in the conversation — a finished agent can never cut FRIDAY off mid-sentence.
3. Async Widget Deck (hub.py, widget_generator.py, web_gui/app.js)
Composing a data-dense card takes seconds. Doing that inside the voice turn would stall the conversation, so the work is split in two:
user speaks
│
▼
Gemini Live ──► speaks the takeaway (1-2 sentences)
│
└──► create_skeleton_widget(widget_id, title, query_context) returns in ~0ms
│
├──► broadcast "create_skeleton" → card appears, shimmering
│
└──► background task: gemini-3.7-flash writes the card HTML
│
▼
broadcast "patch_content" → skeleton fades, card hydratesMeasured end to end: the tool returns at +0.00s, the skeleton is on screen in the same frame, and content patches in at ~4–12s depending on whether the generator needs to search for live figures.
- The hub owns the deck (max 8 cards). A client connecting late gets a full
syncsnapshot; a card dismissed mid-generation is never patched. query_contextis the contract. The generator cannot see the conversation — it receives only that string, so Live is instructed to spell out every figure and section the card should carry.- Everything generated is sanitised before it reaches the DOM.
<script>,<iframe>,<style>,<link>, inlineon*handlers andjavascript:URLs are stripped hub-side. The generator is told not to emit them; the sanitiser is what guarantees it. - The generator writes no CSS. It composes from a fixed set of
hud-*classes defined instyle.css, which is what keeps model-authored markup looking like the rest of the app. - Charts are earned, not decorative. One is drawn only when there is a genuine series over time or a set of comparable magnitudes. A quote card gets a chart; a headlines card, a password or a weather summary does not.
- Images are fetched, verified, then proxied. Two failure modes are real and both were observed: a model reconstructing a plausible CDN path that answers
401, and a genuine article image that answers403to a direct browser request because of hotlink protection. So the hub fetches each candidate itself with a browser user-agent and the image's own origin asReferer, drops whatever cannot be retrieved, and rewrites the survivors to/img?u=…— a local endpoint that streams the bytes from the hub. The browser only ever loads images from localhost, and a card never shows a broken frame.
4. Multi-Monitor Vision & Spatial Targeting (sentry_vision.py)
- Quartz display enumeration — all monitors, scaling factors, desktop arrangement.
- Context-aware capture — the display holding the frontmost window, a specific display, or a labelled composite of all.
- Global coordinate tracking —
LAST_CAPTURE_BOUNDSmaps normalized 0–1000 model coordinates back to physical pixels.
5. Native macOS Desktop Automation (sentry_action.py, sentry_exec.py)
- Hardware-level input via
CGEventPost(kCGHIDEventTap), bypassing PyAutoGUI's single-monitor clamping. - Accessibility inspector (
read_ui_elements) reads the frontmost app's AX tree for exact control positions. - Window & Spaces enumeration (
list_open_windows) across the Quartz Window Server. - Shell and AppleScript execution with timeouts and the remote approval gate.
6. Biometric Face & Voice Recognition (sentry_recognition.py)
- OpenCV YuNet ONNX detection + SFace 128-d embeddings, cosine-matched against
friday_profiles.json. - Voice fingerprinting — pure-NumPy MFCCs pooled by mean and variance from the rolling mic buffer.
7. Spatial Visualization Engine (sentry_scene.py, web_gui/sve.js, web_gui/gestures.js)
- Persistent 3D workspace — scenes live in
friday_scenes.jsonand stay on stage until dismissed. - Incremental delta protocol — update, rotate, recolour, highlight, hide, or explode individual objects; never a full rebuild.
- Screen-sized labels — annotations are sized to a constant on-screen height, depth-tested so geometry occludes them, and decluttered by screen-space overlap (12 visible at once, nearest first).
- Markerless hand tracking — vendored MediaPipe HandLandmarker WASM: point to hover, pinch to grab, pinch empty space to orbit, two-hand pinch to zoom.
8. Generated 3D Assets (asset_generator.py, web_gui/asset_viewer.js)
- Text-to-3D via Tripo3D —
generate_spatial_3d_assetturns a description into a textured.glb. RequiresTRIPO_API_KEY; without it the SVE still works and only this tool is unavailable. - Never on the audio path — generation takes tens of seconds, so the tool returns instantly, mounts a card, and runs the poll loop as a background task. The card shows live progress and the model drops in when it lands, exactly like the widget generator.
- PBR viewer —
GLTFLoaderwith aRoomEnvironmentimage-based light plus cyan key and violet rim lights, orbit controls, and auto-framing (models arrive at arbitrary scale, so each is normalised to unit size and the camera fitted to it). - Downloaded, not hotlinked — the hub fetches the
.glbserver-side and saves it togenerated_assets/, then serves it from/assets. The CDN sends no CORS headers and signs its URLs with an expiry, so a card pointed straight at it fails even though generation succeeded. - Models persist — every asset is content-addressed (
<slug>_<sha1>.glb) and indexed infriday_assets.json.list_3d_assetsshows what exists andshow_3d_assetre-mounts one instantly for free, so asking for the same model twice never costs a second credit. Runtime assets are kept under the configured data directory and are excluded from version control. - Hand-gesture control, on by default — a model takes gesture focus the moment it mounts and starts hand tracking itself, the way a live scene does. Pinch-drag to grab and move it, pinch empty space or open palm to orbit, two-hand pinch to zoom. The card carries the same Hands / Reset view pills as the spatial card, and fills the active 3D workspace.
gestures.jsnow resolves a target rather than callingwindow.SVEdirectly, and a focused card implements the same surface (pickAt/select/moveSelectedTo/orbitCamera/dollyCamera/commitSelectedMove/register+unregisterInputSource). With no card focused the target is the SVE, exactly as before. The gesture cursor and HUD move into the focused card and back on release. - Context-safe — every card owns a WebGL context and browsers cap those, so dismissing a card disposes its renderer, geometry, and textures.
This is not a replacement for the SVE. A generated asset is one photoreal object: no ids, no labels, no edits, variable generation time, and credits per call. Anything structural or editable — molecules, orbits, flowcharts, anatomy, data — stays an SVE scene, which is directly updatable and does not use Tripo credits; Gemini usage still applies. The persona instruction and the tool description both enforce that split.
9. Personal Productivity Suite (sentry_personal.py)
- EventKit calendars via PyObjC across iCloud, Google, and Exchange.
- Apple Mail via AppleScript — read recent mail, search sender/subject.
10. Zero-Trust Remote Access (deploy/setup_remote.sh, Tailscale)
- The hub binds only to
127.0.0.1; remote access is tunnelled through Tailscale Serve HTTPS. - Human-in-the-loop approvals — while a remote client is connected, every shell and AppleScript call suspends for one-tap approval with a 45-second auto-deny.
- Smart sensor routing — the phone's mic and camera become the primary senses; the unattended Mac's webcam and screen are left alone unless asked for explicitly.
Widget design vocabulary
The generator is given this vocabulary and nothing else — no <style> blocks, no invented colours. Every class below is defined in web_gui/style.css:
| Class | Renders |
|---|---|
.hud-hero-stat / .hud-hero-row / .hud-sub | Large monospace headline figure with a glowing accent, its label and caption |
.hud-badge-green / -red / -cyan / -amber | Delta and status pills |
.hud-metric-grid + .hud-metric | Three-column key/value matrix |
.hud-feed + .hud-feed-row | Numbered rows with category tag, headline and brief |
.hud-svg-chart | Wrapper for an inline <svg viewBox="0 0 400 120"> — gradient area fill, glowing stroke, dashed reference line |
.hud-bar | Linear meter |
.hud-note | Closing one- or two-line summary |
Unanticipated markup still lands sensibly: tables, lists, headings, paragraphs and images inside .hud are given baseline styling rather than inheriting browser defaults.
Scene graph, rendering & gesture protocol
Source: SVE.md (updated to the current 3D workspace); friday/desktop/sentry_scene.py; web_gui/sve.js; web_gui/gestures.js
Architecture
User request (voice/text)
│
▼
LLM (Gemini Live / Gemini agents) ← visualization planner: decides
│ tool calls objects, ids, layout, animation
▼
create_3d_scene / update_3d_scene / delete_3d_scene / list_3d_scenes / inspect_3d_scene
│ JSON scene-graph specs & ops
▼
sentry_scene.SceneManager ← validation, persistence
│ (friday_scenes.json), state, diffing
│ incremental op broadcast (WebSocket)
▼
web_gui/sve.js ← rendering backend (Three.js)
│ object cache, animation loop,
▼ interaction manager
3D workspace in FRIDAY GUI ← live scene; user actions stream
back to SceneManagerKey property: object-level updates. "Highlight the left ventricle" becomes one {action:"highlight", id:"left_ventricle"} op — the renderer touches one mesh; nothing is regenerated.
Scene graph
A scene: {id, name, objects: {id → spec}, environment, selected}.
Object spec (renderer-neutral JSON):
| Field | Meaning |
|---|---|
id | Stable handle the AI and user actions refer to ("sun", "left_ventricle") |
type | sphere box cylinder cone torus ring plane line text points group arrow capsule |
position/rotation/scale | Transform; parent nests under a group |
color/opacity/emissive/metalness/roughness/wireframe | Material |
size | Type-specific dims (radius, width, tube, …) |
label | Floating annotation sprite |
points | Polyline vertices (line) |
count, spread | Particle systems (points) |
animation | {type: orbit|spin|pulse|bounce, speed, radius, center, axis} |
hidden, highlighted | State flags |
Environment: {background, grid, stars, ambient, camera:{position,target}}.
Edit ops (update_3d_scene): add, update (merge changes), remove, highlight/unhighlight, hide/show, focus, camera, environment, explode (factor), style (wireframe/solid).
Persistence & memory
- Scenes persist in
friday_scenes.json; reload on restart, pushed to every connecting GUI as ansve_workspacesnapshot. - The AI recalls stage state via
list_3d_scenes/inspect_3d_scene— including which object the user selected by clicking, so "make it red" can resolve "it". - User GUI actions (select, delete object, close scene) stream back via
sve_user_actionand update the same store — AI and GUI never diverge.
Interaction manager
Built-in sources (web_gui/sve.js): - Mouse/touch: OrbitControls (rotate/zoom/pan), raycast click-select. - Keyboard: F focus selected, Del remove selected. - Voice: inherently — any spoken edit becomes scene ops via the LLM.
Hand tracking (implemented — web_gui/gestures.js)
MediaPipe HandLandmarker, fully local (vendored wasm + model, ~27MB in web_gui/vendor/). Toggle with the ✋ Hands button in the 3D toolbar; shares the webcam stream with the preview card (refcounted FridayCamera).
| Gesture | Action |
|---|---|
| Point (index finger) | Cursor + hover info |
| Pinch on an object | Grab and move it (release drops + syncs to backend) |
| Pinch on empty space | Orbit camera |
| Open palm move | Orbit camera |
| Two hands pinching | Zoom: spread = in, squeeze = out |
Cursor is mirrored (hand right → cursor right) and EMA-smoothed.
Other input sources (extension point)
SVE.registerInputSource({update(dt), dispose()}) runs every frame. Helper API for sources: SVE.pickAt(ndc), SVE.select(obj), SVE.selected, SVE.moveSelectedTo(ndc), SVE.commitSelectedMove(), SVE.orbitCamera(dAz, dPolar), SVE.dollyCamera(factor). The same contract fits OpenXR hand tracking, Leap Motion, or game controllers; the scene graph does not know about them.
Rendering backend abstraction
The scene graph and op stream are engine-neutral JSON. sve.js is the Three.js implementation. To swap engines (Babylon.js, native Metal/Vulkan viewer, WebXR renderer): implement the same three entry points —
- consume
sve_workspace(full state),sve_scene_create,sve_scene_update(ops),sve_scene_delete; - emit
sve_user_actionmessages; - honor the object spec table above.
No Python changes needed.
Extending the vocabulary ("plugins")
Domain plugins are additions at two layers:
- New primitive types (only when composition can't express it): add a case to
_sanitize_object(sentry_scene.py) and tobuildObject(sve.js). Example:molecule_bond,terrain,gltf(load external models). - Domain knowledge lives in the LLM prompt/tool description — e.g. a Chemistry preset is a prompt fragment teaching element colors (CPK) and bond conventions, not code. Add such fragments to the system instruction when needed.
Live data feeds (CPU, telemetry): broadcast sve_scene_update ops from any backend task (e.g. a psutil loop emitting {action:"update", id:"cpu_bar", changes:{scale:[1, load, 1]}}) — the renderer already applies streamed ops continuously; no new mechanism required.
Current limits (honest list)
- Hand tracking is single-viewport 2D-projected (no depth grab); rotation and scale gestures per-object not bound yet — two-hand zoom moves the camera.
- VR/AR/OpenXR, Unity/Unreal backends: out of scope; abstraction supports them.
textrendering uses canvas sprites (billboards), not extruded 3D type.- One viewport; scenes switch via tabs (all stay resident and animated state is preserved — only the active one renders).
- Drag-moving objects with the mouse: not bound yet (select/focus/delete are).
Scene and asset distinction
The scene-graph operation protocol applies to SVE objects. A Tripo-generated GLB is loaded by the asset viewer as a model, rather than being exposed as independently editable SVE object IDs. Input-source interfaces are shared so hand tracking follows whichever 3D surface is visible.
Dashboard rendering & interaction model
Source: readme.md; friday/sentinel/dashboard/
The sentinel serves its own admin console at /. It is static ES modules under friday/sentinel/dashboard/, with no bundler and no framework, and a Tailwind build committed alongside them, so nothing compiles on the Pi.
Pages:
- Overview: every node's heartbeat and telemetry tiles, and the Watching and WhatsApp cards.
- Activity: the event bus as it happens.
- Triage: what the monitors found and what was decided.
- Meetings: uploads, pipeline state,
#Mcodes and retry. - Controls, Assistant and Settings, described below.
- Nodes & tokens: issue and revoke node tokens.
The shell. The sidebar and header are pinned. The page is a fixed-height frame (h-dvh overflow-hidden, the dynamic viewport height, so a phone's browser toolbar never hides the bottom of the app), and only the content area (<main>) scrolls, so the navigation and the connection badge (live, reconnecting or offline) never scroll away. Both bars sit on a translucent, blurred background over zinc borders. Below the md breakpoint the sidebar becomes a tab strip under the header.
- Full-bleed pages. A page may declare itself full-bleed (
export const layout = "full"). The router then gives it the whole content area with no padding and no page scroll; this is how the Assistant lays out its own columns. - Scroll. Every navigation starts the new page at the top.
- Leaving a page that is still loading. Each page mounts into its own host element. If you leave before it has finished loading, it is undone completely: its late content, its listeners, any voice session and the orb's render loop.
Settings. Every dynamic setting in the vault appears on one page, declared in the settings registry, grouped into five domain cards:
| Card | What it holds |
|---|---|
| Model & Core | The Gemini and Tripo keys, the per-role model routes, the sentinel's time zone, intervals and retention, the desktop voice |
| Alert Escalation & Channels | The escalation policy (VIPs, mutes, critical keywords, quiet hours, digest times, reminders, expiry) and the WhatsApp channel |
| Sources & Monitors | Google, IMAP and Jira credentials, the JQL watch query, poll intervals and the calendar look-ahead |
| Minutes & Ingestion | Your names in transcripts, auto-calendar, upload and voice-note limits, retention |
| Telephony & Voice Agent | The phone's adb address, call audio devices, ring and call timing, the dashboard voice |
- Rows. Each row reads left to right:
- On the left: a plain-language label; a one-line description; the config key as a small monospace badge; and where the value comes from (
default,vaultorenv). - On the right: the control.
At md and wider the two columns split 40/60; on a phone they stack. - Controls by type:
| Setting type | Control |
|---|---|
| Flag | Switch |
| Short choice (three options or fewer) | Segmented buttons |
| Longer choice (such as the five Live voices) | Dropdown |
| Number | Box with its unit ([ 20 ] s, [ 50 ] MB) |
| List | Comma-separated field |
| Secret | Masked field showing only set (…cdef), never the value |
| Shell template | Monospace field |
- Advanced blocks. Rarely touched or risky settings sit in each card's collapsed Advanced block. Telephony's is called Advanced Hardware Templates and holds the adb dial, answer and hang-up commands.
Edits are held until you save; nothing is saved as you type.
- Marking and counting. An edited row is marked with an accent. A bar pinned to the bottom of the page counts the edits ("3 unsaved changes") and offers Discard and Save changes.
- Save. It sends every change in one request.
- If the server refuses a value, its reason appears under that field. An Advanced block holding it opens, and the page scrolls to the first problem. Nothing is marked saved.
- A secret you typed is cleared from its field once it has been saved.
- Changes made elsewhere. If a setting changes while you are editing (another tab, a WhatsApp command, the CLI), untouched rows update in place and your edits are kept.
- Leaving. Navigating away or closing the tab with unsaved edits asks first.
Labels, units, the Advanced flag and the card layout are declared once, in friday/sentinel/settings_registry.py, and served by GET /api/settings/schema. A test fails if a setting ships without a label, or with a unit its key contradicts.
Controls. These are the live switches: call mode, do not disturb, WhatsApp, telephony, each monitor and triage, plus the sentinel's timing.
- Instant apply. Unlike Settings, every change takes effect the moment you make it. A switch flips and is saved at once.
- Refusals. If the server refuses a change, the switch moves back and the reason is shown.
- Timing. A timing number applies when you press Enter or leave the field.
Assistant. A split view: 40% visualiser on the left, 60% conversation on the right, divided by a single hairline and drawn on the page background without boxed cards.
- The visualiser.
- The orb: Three.js, shared with the desktop HUD.
- A status pill: Idle, Listening, Thinking, Speaking or Offline, with "· muted" added while the mic is muted.
- The voice controls: Start voice (which becomes End), and Mute mic, which keeps the session open but sends no audio.
- The conversation.
- Your messages are tinted bubbles.
- FRIDAY's replies are rendered as markdown: headings, bold and italic, lists, quotes, inline and fenced code, links, and tables with column alignment.
- Tools. Each tool FRIDAY used appears as a small
name · mschip. - Voice. A voice session's transcripts land in the same stream as they are spoken, marked 🎙.
- Streaming. Tokens accumulate, and a reply is redrawn at most once per animation frame, so text appears smoothly without the content above it moving.
- Scrolling. The view follows new text while you are at the bottom. Scroll up even slightly and it stays put until you return to the bottom or send a message.
- Interruptions. If you interrupt FRIDAY by voice, her cut-off reply stays as it was and the next one starts a new bubble.
- Shortcuts. ⌘K or
/focuses the message box; ⌘/ starts or ends voice. - Leaving and returning. Leaving the page ends the voice session and stops the orb's render loop. Coming back re-attaches the same canvas, so the orb is always there.
How it is built:
| Module | Responsibility |
|---|---|
app.js | Sign-in state, the router (page layouts, scroll reset, the unsaved-changes guard), the sidebar and the event socket |
fields.js | The setting row and its controls, shared by Settings and Controls |
form.js | Dirty tracking behind the save bar: what changed, what to send, what to keep when the server changes underneath |
chat.js | The conversation column: bubbles, once-per-frame streaming renders, follow-the-tail |
md.js | Markdown to DOM, escape-first (see below) |
views/voice.js | The browser half of the voice session: 16 kHz mic up and 24 kHz speech down over /voice/ws, mute, and the orb's lifecycle |
friday/webassets/orb.js | The orb, mounted on every visit to the Assistant and unmounted on leaving, one WebGL context for the page's life |
md.js only ever creates elements and text nodes and never parses HTML, so nothing in a reply can become markup. Links must be http(s), and table alignment comes from a fixed list.
After changing any class, run deploy/build_css.sh.
tests/sentinel/test_dashboard_files.pyfails on a stale build. It also checks each page's wiring, and renders markdown over every prefix of a streamed reply.tests/sentinel/test_dashboard_js.pyruns the dashboard's modules in node against a small DOM shim, and is skipped where node is not installed. It covers:- dirty tracking, including an edit typed while a save is in flight;
- the controls by type;
- once-per-frame streaming and follow-the-tail;
- the voice session's barge-in and end signals;
- the orb's remount.
Monitoring, triage & channel internals
Source: readme.md; friday/sentinel/sources, triage, bridges/whatsapp, escalation, minutes, telephony; current policy.py
friday/sentinel/sources/ holds one capability object per surface, each exposing only the verbs it is allowed to use: GmailAccount (read + draft), ImapAccount (read + draft by APPEND), CalendarGuarded (read, create, and modify only what it stamped as its own), and JiraReadOnly (one _get, no write method to call). Two tests enforce the promises rather than trusting them: no module under friday/ may import smtplib, and no executable string in sources/ may name a send endpoint.
friday/sentinel/watch.py polls each source on its own interval, honours the controls.monitors.* switches live, deduplicates through watch_items (the INSERT is the dedupe) and publishes monitor.item. A source that fails goes degraded and backs off; a revoked credential goes needs_reauth and stops retrying, because only a human can fix it. Credentials live in the vault under sources.*; Google consent is a one-time friday-sentinel google-auth.
Deciding
friday/sentinel/triage/ is the deciding half: rules.py (deterministic, ordered, each rule names itself in the verdict), model.py (one forced record_triage call whose parameters are enums, a per-hour budget, and a fallback ladder that can never return critical), policy.py (one pure function from verdict plus controls to an action), handler.py (the bus handler that judges, claims and dispatches), drafts.py (a suggested reply written to Drafts, never sent) and digest.py (the scheduled pile).
The at-least-once bus means an item can arrive twice, so dispatch is guarded by watch_item_claim_decision — an UPDATE … WHERE decision = '' whose Boolean is the guard. One item yields at most one call, one alert and one draft, however many times it is redelivered.
Alerts travel over the existing /ws to friday/desktop/alerts.py, which shows a card on the HUD and queues the spoken line behind whatever FRIDAY is already saying.
Everything is configuration, live and unencrypted: policy.vip and policy.mute decide who matters, policy.keywords_critical decides what does, policy.quiet_hours (which may cross midnight) and policy.digest_times decide when, policy.draft_replies opts into writing drafts, and policy.model_budget_per_hour caps what the model may cost. controls.triage is the master switch and ships off; sentinel.timezone is the zone all of that is read in.
Sending
friday/sentinel/bridges/whatsapp/ is the outbound half: jid.py (one destination, spelled one way), protocol.py (newline-delimited JSON, both directions), sidecar.py (a child process kept alive through exits, hangs, garbage and lock-outs) and bridge.py (the bus handler, the outbox and the paced sender). sidecar/whatsapp/ is a separate Go module — go.mau.fi/whatsmeow plus a pure-Go SQLite driver so it cross-compiles for the Pi with no toolchain there — and nothing in friday/ imports it.
The handler's only job is to write the row down, because the bus gives it three attempts and a phone can be off for a day; the sender loop then owns delivery with its own pace and patience. The event id is the dedupe key, so an at-least-once redelivery queues one message. The paired session is the only credential: it lives in a file only the sidecar opens, guarded by an advisory flock so the daemon and whatsapp-pair can never fight over it.
The pace is deliberate, because a burst is what gets an account banned: whatsapp.min_gap_s and whatsapp.max_per_hour space the queue out, whatsapp.max_body_chars truncates a long digest below WhatsApp's own limit, and anything still undelivered after whatsapp.expire_after_h is expired and audited rather than arriving a day late. whatsapp.to is the single destination, validated and normalised to <countrycode><number>@s.whatsapp.net; controls.whatsapp is the live switch and ships off. This is an unofficial client, so the number it pairs is the secondary one.
Listening
bridges/whatsapp/inbound.py is the gate: authorise, then deduplicate before dispatching the turn. WhatsApp replays offline messages; already-answered IDs are ignored. The current bridge does not cap the authorised owner's inbound message count; outbound pacing and per-message input limits still apply. conversation.py runs the turn: one thread per local day, the per-conversation lock the dashboard already uses but awaited rather than refused, run_turn with via="whatsapp", and one reply through markdown.py into the outbox with a deterministic id. The turn runs as a tracked task rather than inside the bus handler: the bus cancels a handler after 30 s and retries it, so a slow tool loop run inline would be cut off and run again, tools and all. As a task it keeps its own 180 s budget, the event completes once, and alerts queued behind it are not held up.
Resolving who sent it happens in Go, because recent WhatsApp releases set the sender to a hidden <opaque>@lid address rather than a phone number: resolveSender tries the sender, then the alternative address whatsmeow supplies, then the LID-to-phone mapping store, and forwards nothing it cannot identify. Groups and FRIDAY's own messages are dropped before their text crosses the pipe.
Acting on alerts
friday/sentinel/escalation/ follows an alert through. notifier.py turns every call or alert decision into a WhatsApp message carrying a short code (#3), written in the same transaction as the escalation row and a reply ref. commands.py is a strict grammar: ack, dismiss, not urgent and snooze 2h | 90m | 1d | tomorrow, optionally with #3. Only a whole message counts, so "ack, on it" stays a sentence for the assistant. actions.py resolves which alert a command means and moves it with one conditional UPDATE. runner.py re-raises open critical alerts and wakes snoozed ones.
Resolution never guesses. An explicit code wins, then the alert the reply quoted (the sidecar forwards the quoted message id, and whatsapp_refs maps it back), then the only live alert. With two live alerts and no pointer, FRIDAY asks which one. Commands wait behind the same per-conversation lock as turns, never reach the model, and are audited as user:<name> via whatsapp (escalation action). ack, dismiss and not urgent also write a labelled row to triage_feedback for future policy tuning.
Minutes of meeting
friday/sentinel/minutes/ turns a recording or transcript into minutes:
transcribe.pyreads.vtt,.srtand.txtlocally and sends audio to Gemini through the Files API, deleting the upload afterwards.extract.pymakes one forcedrecord_minutescall over a fenced, untrusted transcript and validates every field.render.pywrites the WhatsApp text and splits it underwhatsapp.max_body_charsas[1/3]parts.runner.pyis the pipeline. Each meeting's state lives in its row (received → transcribed → ready → delivered), so a restart resumes and a step never runs twice.
Minutes arrive with a code (#M1). Quoting any part, or sending #M1 tasks, #M1 decisions, #M1 transcript or #M1 add <n>, routes to that meeting. A question asked by quoting the minutes runs one assistant turn with that meeting as context. Action items that are yours (minutes.my_names) and dated in the future go on the calendar once each.
Meetings arrive from four places, and all of them go through the same pipeline: - The minutes add CLI. - The dashboard's Meetings page, a streamed upload with the pipeline state, #M codes and retry. - WhatsApp. The sidecar reports a file as metadata, and only after the sender is authorised does Python ask it to download. Uncaptioned audio up to minutes.voice_note_max_s seconds is a voice note, transcribed and answered as a question. Longer audio, and any .vtt/.srt/.txt or audio document, becomes a meeting. - Google Meet. The meet monitor reads new transcript documents you own from Drive, read-only. A 403 is read by its reason: a disabled API or a rate limit is not needs_reauth, and a file Google will not export is skipped.
The call agent
friday/sentinel/telephony/ puts FRIDAY on a phone line:
adb.pydrives the secondary phone over wireless adb, using dial, answer and hang-up commands that are settings (Settings → Telephony & Voice Agent → Advanced Hardware Templates) because which one works depends on the ROM. It readsdumpsys telecom(has the outgoing call goneACTIVE?) anddumpsys telephony.registry(ringing, and who). It turns every failure into a reason: unreachable, permission denied, or switched off.audio.pycarries the call audio through a USB sound card witharecordandaplay. No Python audio library is needed.session.pyruns the Live engine on that line. FRIDAY speaks first and the transcript is kept.bridge.pyplaces alert calls, answers incoming ones, and keeps them to one line.
A critical alert whose policy decision is call rings your phone. Say "acknowledge" or "snooze two hours" and the alert is updated exactly as a WhatsApp reply would update it. Before every dial and redial, the bridge checks the alert, so anything you have already acked, dismissed or snoozed is never rung. Every way a call can fail sends the alert back to WhatsApp, saying why: the phone was unreachable, it refused to dial, or there is no audio. Incoming calls are answered when the controls allow it. Your number gets the owner persona, read-only because caller ID can be faked. Anyone else gets a message-taking agent with one tool that never says your number. Every answered call becomes minutes. A voice model that never connects, or an answer the phone never picks up, goes back to WhatsApp instead.
The Live engine
friday.core.live owns every Gemini Live session in the project: connect, resume, rotate on GoAway, decode the stream into typed events (AudioOut, TextOut, Transcript, Interrupted, TurnComplete, ToolStarted/ToolFinished, GoAway), and answer tool calls — including the ones that raise, because an unanswered turn never closes and the model re-issues it after every resume. Consumers push media with send_audio / send_video / send_text and react to session.events(). An optional AudioTransport lets the session pump audio itself: the dashboard passes a WebSocket transport, the desktop hub passes none and keeps its own mic/playback multiplexing, and the call agent uses an ALSA transport.
Event dispatcher and lifecycle
EventBus defaults to 16-event claim batches, a 1-second poll, three total handler attempts, and a 30-second timeout per handler. It processes events sequentially. All handlers whose glob patterns match are invoked; successful handlers can see an event again if another handler fails, so handlers must remain idempotent. These dispatcher retries are immediate, unlike the independent backoff used by sources and the WhatsApp outbox.
publish validates the event, writes it to SQLite, then wakes the dispatcher. Queue states are pending, processing, done, and failed; exhausted attempts park the item as failed. Shutdown stops acceptance, stops monitors and bridges, drains the current dispatch batch, checkpoints, and closes storage within the daemon's bounded shutdown window. systemd readiness and watchdog notifications follow the daemon's lifecycle and heartbeat.
Exact triage policy ordering
The current policy is a pure function; it takes a verdict, controls, meeting state, quiet-hours state, repeated-thread state, and whether the item is mail. A rule description alone does not bypass this final policy.
| Condition, evaluated in order | Result |
|---|---|
| Repeated critical/high item in the repeat window | Digest, suppressed by repeat |
| Low urgency | No action |
| Normal urgency | Digest |
| Critical, no DND/quiet/meeting gate, and call mode permits it | Call; speak; optionally draft if mail |
| Critical with a gate or muted call mode | Visual alert; no speech flag; optionally draft |
| High with DND, quiet hours, or meeting gate | Digest; suppression reason recorded |
| High, no gate, call mode always | Call; speak; optionally draft |
| High, no gate, another call mode | Alert; speak; optionally draft |
always permits calls for high and critical urgency. urgent_only permits calls only for critical urgency. mute prevents calls; it does not mean every possible alert is discarded. Gate precedence is DND, then quiet hours, then meeting. A digest created during quiet hours is held until the quiet period ends. WhatsApp notification and reminder handling are separate stages from the spoken-alert flag.
Assistant execution contract
The Sentinel assistant uses the assistant model route through the provider-neutral Message, Chunk, ToolCall, generate, and stream interfaces. The current tool set has ten declarations; the full catalog is below. Incoming-call tools are a restricted subset rather than the same authority as an authenticated dashboard session.
- Model/tool loop cap: 8 steps per turn.
- Model step timeout: 60 seconds. Tool timeout: 15 seconds.
- History character budget: 24,000. Tool result limit: 8,000 characters.
- Dashboard message content: 1–8,000 characters; simultaneous turns in one conversation receive HTTP 409.
- WhatsApp turns wait for the conversation lock and run as supervised tasks with a 180-second turn budget, outside the 30-second event-handler budget.
set_controlsexposes call mode, DND, email/calendar/Jira monitor switches, and an allowlist of telemetry, heartbeat, event retention, and chat retention settings. It does not expose arbitrary vault writes, secrets, or model-route editing.create_reminderresolves a supplied ISO datetime in the configured timezone, validates it, and rejects past/unparseable times before calendar creation.- Gemini tool-call thought signatures are preserved in conversation history so a subsequent request can replay the call correctly.
Source failures and input boundaries
Each source polls on its own configured interval. Insertion into watch_items is the deduplication guard. Temporary failures produce a degraded state and backoff; revoked credentials stop retrying and require reauthorisation. A disabled Drive API is distinguished from expired authorisation. Export-disabled or oversized Drive transcripts are skipped and logged. IMAP drafts use APPEND, and the application has no SMTP sending path.
Only text from an authorised WhatsApp owner reaches conversation handling. Media begins as metadata; download happens after authorisation and size checks. Hidden @lid identities must resolve to a phone number before forwarding. Group and self messages are dropped. Unknown recipients cannot redirect outbound delivery.
Escalation state and idempotency
The initial WhatsApp alert, short-code escalation record, and reply reference are written together. The exact-command parser chooses an explicit code first, then a quoted message reference, then a single open alert. It never guesses between several alerts. Conditional updates make repeated commands safe, and actions are audited with the WhatsApp actor. Acknowledged, dismissed, or snoozed alerts are checked before every phone dial or redial and will not be rung again while resolved or deferred. DND-held alerts also fail the dial guard.
Meeting processing and call authority
Text transcripts are read locally. Audio is uploaded to Gemini's Files API for transcription, and that upload is deleted afterwards. The extractor fences the transcript as untrusted content, forces a record_minutes call, and validates the result. Each pipeline state is persisted; retry resumes work rather than recreating completed steps. Calendar insertions for action items are deduplicated. Failed recordings are retained for a configurable retry period; successful recordings are removed after transcription.
Outbound calls target the configured number; acknowledgements and snoozes can change the corresponding alert. Incoming owner calls provide read-only answers because caller ID can be spoofed. Other callers receive the message-taking persona. A call that fails to dial, answer, establish audio, or connect its voice model returns a reason through the configured WhatsApp channel. Ring timeout must be shorter than voicemail pickup, which otherwise looks like an answered call.
HTTP, events & streaming protocols
Source: readme.md; friday/sentinel/api.py; web.py; voice.py; friday/core/events.py
| Route | Auth | Purpose |
|---|---|---|
GET /health | none | status, queue depths, platform, supervisor restarts |
GET /telemetry | node token or session | latest telemetry snapshot (204 if none yet) |
GET /nodes | node token or session | last heartbeat per node |
POST /events | node token or session | one event or a list (≤100, ≤256 KB) → 202 {"ids":[...]} |
GET /ws | node token or session (header, cookie or ?token=) | stream events; send {"subscribe":["node.*"]} to filter |
POST /auth/login | credentials + client header | create the dashboard session (cookie: HttpOnly, SameSite=Lax, Secure over HTTPS) |
POST /auth/logout · GET /auth/me | session | sign out or inspect the authenticated user |
GET/PUT /api/settings, GET /api/settings/schema | session | vault-backed settings; secrets masked. The schema carries each setting's label, unit and Advanced flag, and the five Settings cards (sections) |
GET/POST /api/tokens, DELETE /api/tokens/{id} | session | node tokens (plaintext shown once) |
GET /api/audit | session | who changed what |
GET /config?scope=desktop | node token or session | decrypted config for a node scope; audited |
GET /api/events?type=&source=&since=&before=&limit= | session | events, newest first (type is a glob) |
GET /api/telemetry | node token or session | latest snapshot per node |
GET /api/watch | session | per-source state and the last ten items seen |
GET /api/triage | session | judged items, urgency counts, and whether triage is on |
GET /api/digest/preview | session | what the next digest would say |
GET /api/whatsapp | session | bridge state, queue counts, the age of the oldest unsent message, and today's inbound counts |
GET/POST /api/chat, GET/DELETE /api/chat/{id} | session | assistant conversations |
POST /api/chat/{id}/messages | session | one turn; application/x-ndjson stream of delta / tool / result / error / done |
GET /voice/ws?conversation=<id> | node token or session | Live voice: binary PCM both ways, JSON control frames |
An event is a small JSON envelope; type is dotted lowercase and payload is free-form:
{"type": "sensor.update", "source": "office-esp32", "payload": {"temp_c": 24.5}, "priority": 0}Deployment (systemd unit, launchd plist, Tailscale Serve) is documented in deploy/README.md.
Authentication and status codes
/health is public. Node APIs accept a dashboard session or a node token in Authorization: Bearer fn_…. Dashboard-management APIs require a user session. /auth/login creates that session from credentials; it does not require an existing session. Login and all state-changing dashboard endpoints require X-FRIDAY-Client: dashboard; an Origin header, when supplied, must match the expected host. The server does not expose CORS permission for arbitrary origins.
Typical failures are 400 for validation, 401 for missing or invalid identity, 403 for CSRF/origin rejection, 404 for an unknown resource, 409 for a conversation already running a turn or voice session, 413 for oversized input, 429 for login throttling, and 503 when browser voice is disabled.
Meeting routes
| Route | Purpose | Identity |
|---|---|---|
| GET /api/meetings | List meeting pipeline state | User session |
| POST /api/meetings | Stream multipart upload; file with optional title and date | User session + client header |
| GET /api/meetings/{id} | Inspect a meeting's minutes and actions | User session |
| POST /api/meetings/{id}/retry | Requeue a failed meeting | User session + client header |
The multipart receiver applies minutes.upload_max_mb; this is independent of the event-ingestion JSON size limit. Upload limits and recognised formats are configurable or defined by the minutes transcriber.
Event envelope and acceptance
An event has id, ts, source, type, payload, and priority. The server generates a UUID-hex ID and Unix timestamp when absent. Type must match dotted lowercase components (^[a-z0-9_]+(\.[a-z0-9_]+)+$); source and ID must be nonempty strings; payload must be a JSON-serialisable object; timestamp must be numeric and priority an integer, with booleans rejected for both numeric fields.
POST /events accepts a single envelope or an array of up to 100, with a maximum body of 256 KiB. The full batch is validated before publication. Each event is committed before its ID is acknowledged. HTTP 202 means accepted into the queue, not that downstream work has finished.
{
"type": "sensor.update",
"source": "office-sensor",
"payload": {"temp_c": 24.5},
"priority": 0
}{"ids":["server-generated-event-id"]}Event WebSocket
GET /ws defaults to all events. Send a nonempty list of nonempty glob strings to change the subscription. The server acknowledges the patterns and pushes full event envelopes. This endpoint accepts the token header, session cookie, or a token query parameter; the query-token allowance is specific to this route. Prefer headers where the client supports them.
{"subscribe":["node.*","telemetry.sample","triage.*"]}{"subscribed":["node.*","telemetry.sample","triage.*"]}The socket heartbeat is 20 seconds. Fanout allows two seconds per socket send; a slow/dead socket is dropped without failing the persisted event. This is live fanout, not a replay cursor. Use /api/events for historical inspection after reconnecting.
Streaming text chat
Create a conversation with POST /api/chat and an optional title. Send {"content":"What did I miss?"} to /api/chat/{id}/messages. The response is newline-delimited JSON, with application/x-ndjson, Cache-Control: no-store, and X-Accel-Buffering: no. A client must parse complete newline-terminated JSON frames rather than assuming transport chunks match messages.
Frames include text delta, tool start/result records, errors, and a final done. Tool chips and text rendering update incrementally. Conversation and message history persist in SQLite; the configured chat-retention period prunes idle threads. Tool output is data rather than authority to issue further instructions.
Voice WebSocket frames
Connect to /voice/ws?conversation=<id> with a session or node-token header. An absent/unknown conversation ID creates a conversation. A second voice session for the same conversation is rejected with 409. This socket has a 20-second heartbeat and a 4 MiB frame-size ceiling. Binary input is mono PCM16 at 16 kHz; output is mono PCM16 at 24 kHz.
| Direction | Frame | Meaning |
|---|---|---|
| Client → server | Binary PCM | Microphone samples |
| Client → server | JSON type=text, content=… | Text input to the Live session |
| Server → client | state: connecting + conversation_id | Session bootstrap |
| Server → client | state: live / reconnecting / closed | Connection state |
| Server → client | Binary PCM | Model speech |
| Server → client | transcript with role, text, final | Accumulated transcription |
| Server → client | tool with phase=start/done | Name/args, then output and elapsed milliseconds |
| Server → client | clear | Drop buffered playback on interruption |
| Server → client | turn_complete | End of the current conversational turn |
| Server → client | error with message | Configuration or session failure |
The client stops sending audio when muted. Closing the socket tears down the session. Transcript fragments accumulate by speaker; final fragments and turn completion flush them into the conversation with via=voice. Session duration is audited.
Shared Live engine
friday.core.live.LiveSession owns connection, turn receive loops, GoAway rotation, resumption, typed events, and tool answers. Consumers send audio, video, and text, and read an asynchronous event stream. Events include Connected, Disconnected, AudioOut, TextOut, Transcript, Interrupted, TurnComplete, ToolStarted, ToolFinished, and GoAway. A failing tool still receives a tool response so a turn can close. Resumption handles are held within a process run, not as cross-restart conversations.
The optional AudioTransport interface supplies start/read/write/clear/stop operations. Dashboard voice uses a WebSocket transport, the phone agent uses ALSA, and Desktop manages its own microphone/playback multiplexing. The text-provider abstraction does not make the Gemini Live wire protocol provider-neutral.
Complete tool catalog & parameter schemas
Source: Literal declarations in friday/desktop/hub.py and friday/sentinel/assistant.py
Desktop: 38 tools
Expand a tool to inspect its current declaration and complete parameter schema. These are model-facing contracts from the source; runtime permission gates still apply.
execute_shell_command
Execute a local shell command on macOS and return its output.
{
"type": "OBJECT",
"properties": {
"command": {
"type": "STRING",
"description": "The shell/bash command to run."
}
},
"required": [
"command"
]
}execute_applescript_task
Execute macOS AppleScript code to control native applications, window management, or system settings.
{
"type": "OBJECT",
"properties": {
"script": {
"type": "STRING",
"description": "The AppleScript code to run."
}
},
"required": [
"script"
]
}look_at_screen
Capture a screenshot and load it into your visual sensor. By default captures the ACTIVE display — the monitor where the user's mouse cursor currently is (usually where they are working). The user may have multiple monitors: pass display='all' to see every monitor at once, or a display number ('1', '2') for a specific one. If the user says you're looking at the wrong screen, try display='all' first to locate their work, then capture that display number.
{
"type": "OBJECT",
"properties": {
"display": {
"type": "STRING",
"description": "'active' (default, monitor with mouse), 'all' (composite of all monitors), or a display number like '1' or '2'."
}
}
}look_at_webcam
Capture a single camera frame and load it into your visual sensor. Use this when the user asks you to look at them, check the camera feed, or see their physical surroundings. When Vince is connected remotely from his phone, this automatically uses his PHONE camera; pass source='mac' only if he explicitly asks for the laptop/Mac webcam.
{
"type": "OBJECT",
"properties": {
"source": {
"type": "STRING",
"description": "'auto' (default: phone camera during a remote session, else Mac webcam) or 'mac' to force the Mac's webcam."
}
}
}start_camera_stream
Start continuous real-time camera streaming. Use this when you decide you need to watch the user, check their movements, recognize their face, or see what they are doing in real-time. When Vince is connected remotely from his phone, this automatically streams his PHONE camera; pass source='mac' only if he explicitly asks for the laptop/Mac webcam.
{
"type": "OBJECT",
"properties": {
"reason": {
"type": "STRING",
"description": "The reason why you need to enable the camera feed."
},
"source": {
"type": "STRING",
"description": "'auto' (default: phone camera during a remote session, else Mac webcam) or 'mac' to force the Mac's webcam."
}
},
"required": [
"reason"
]
}stop_camera_stream
Stop the continuous webcam video stream. Call this when you no longer need to watch the user, or when they ask you to turn off the camera.
{
"type": "OBJECT",
"properties": {}
}start_screen_stream
Start continuous real-time streaming of the screen captures. Use this when you need to watch their display activities, code editor updates, or work progress in real-time.
{
"type": "OBJECT",
"properties": {
"reason": {
"type": "STRING",
"description": "The reason why you need to enable the screen feed."
}
},
"required": [
"reason"
]
}stop_screen_stream
Stop the continuous screen capture stream. Call this when you no longer need to monitor their display.
{
"type": "OBJECT",
"properties": {}
}register_person
Register a new person in your local database. Captures their face signature and voice signature, and saves them under their name.
{
"type": "OBJECT",
"properties": {
"name": {
"type": "STRING",
"description": "The name of the person being registered (e.g. 'Vince', 'Anu')."
}
},
"required": [
"name"
]
}identify_current_user
Analyze the active audio buffer (voice signature) and camera frames (face signature) to identify who is speaking or in front of the computer. Returns their name if registered.
{
"type": "OBJECT",
"properties": {}
}save_memory_fact
Save a key-value fact, preference, or detail about the user (e.g. user_name, user_hobbies, facts to remember) to persistent memory. Use this whenever the user asks you to remember something.
{
"type": "OBJECT",
"properties": {
"key": {
"type": "STRING",
"description": "The name/category of the fact (e.g. 'user_name', 'favorite_food')."
},
"value": {
"type": "STRING",
"description": "The detail/fact content to save."
}
},
"required": [
"key",
"value"
]
}retrieve_memory_facts
Retrieve all facts, preferences, and details saved in your persistent memory.
{
"type": "OBJECT",
"properties": {}
}create_3d_scene
Create a live, persistent, interactive 3D scene in the Spatial workspace (renders instantly in the GUI and stays active). Use for ANY visualization request: astronomy, anatomy, architecture, flowcharts, networks, timelines, physics, molecules, data. Build scenes from primitive objects. objects_json is a JSON array of objects: {id, type (sphere|box|cylinder|cone|torus|ring|plane|line|text|points|group|arrow|capsule), position [x,y,z], rotation, scale, color '#hex', opacity, emissive, wireframe, label, parent (group id), size {radius|width|height|depth|tube|innerRadius|outerRadius}, points [[x,y,z],...] for line, text for text nodes, count+spread for points, animation {type: orbit|spin|pulse|bounce, speed, radius, center, axis}}. Give every meaningful object a human id ('sun', 'left_ventricle') and label. NEVER create a new scene for edits to an existing one — use update_3d_scene.
{
"type": "OBJECT",
"properties": {
"name": {
"type": "STRING",
"description": "Scene name shown in the workspace, e.g. 'Solar System'."
},
"objects_json": {
"type": "STRING",
"description": "JSON array of object specs (see tool description)."
},
"environment_json": {
"type": "STRING",
"description": "Optional JSON: {background '#hex', grid bool, stars bool, ambient 0-3, camera {position [x,y,z], target [x,y,z]}}."
}
},
"required": [
"name",
"objects_json"
]
}update_3d_scene
Edit an EXISTING live scene with object-level operations — never recreate a scene to change it. operations_json is a JSON array of ops: {action:'add', object:{...}} | {action:'update', id, changes:{any object fields}} | {action:'remove', id} | {action:'highlight'|'unhighlight'|'hide'|'show', id} | {action:'camera', camera:{position,target}} | {action:'environment', environment:{...}} | {action:'explode', factor} | {action:'style', mode:'wireframe'|'solid'}. Examples: rotate object = update rotation; make transparent = update opacity; zoom into X = camera op targeting X's position.
{
"type": "OBJECT",
"properties": {
"scene": {
"type": "STRING",
"description": "Scene name or id."
},
"operations_json": {
"type": "STRING",
"description": "JSON array of operations (see tool description)."
}
},
"required": [
"scene",
"operations_json"
]
}delete_3d_scene
Remove a scene from the Spatial workspace. Only when the user asks to close/delete it.
{
"type": "OBJECT",
"properties": {
"scene": {
"type": "STRING",
"description": "Scene name or id."
}
},
"required": [
"scene"
]
}list_3d_scenes
List all active scenes in the Spatial workspace with their object ids and the user's current selection.
{
"type": "OBJECT",
"properties": {}
}inspect_3d_scene
Get the full JSON state of one scene (all objects with positions, colors, animations). Use before editing if unsure of current object ids or state.
{
"type": "OBJECT",
"properties": {
"scene": {
"type": "STRING",
"description": "Scene name or id."
}
},
"required": [
"scene"
]
}fetch_webpage
Fetch a specific URL from the internet and return its readable text content. Use this after google_search to read full articles, documentation, or any page the user asks about.
{
"type": "OBJECT",
"properties": {
"url": {
"type": "STRING",
"description": "The full http(s) URL to fetch."
}
},
"required": [
"url"
]
}computer_click
Click the mouse at a position on screen. Coordinates are NORMALIZED 0-1000 relative to the full screen (as seen in your latest screenshot: x=0 left edge, x=1000 right edge, y=0 top, y=1000 bottom). ALWAYS call look_at_screen first to see the current screen, then click. After clicking, call look_at_screen again to verify the result.
{
"type": "OBJECT",
"properties": {
"x": {
"type": "INTEGER",
"description": "Normalized horizontal position 0-1000."
},
"y": {
"type": "INTEGER",
"description": "Normalized vertical position 0-1000."
},
"button": {
"type": "STRING",
"description": "'left' (default), 'right', or 'middle'."
},
"clicks": {
"type": "INTEGER",
"description": "1 = single click (default), 2 = double click."
}
},
"required": [
"x",
"y"
]
}computer_type
Type text with the keyboard into the currently focused field. Click the target field first with computer_click.
{
"type": "OBJECT",
"properties": {
"text": {
"type": "STRING",
"description": "The text to type."
},
"press_enter": {
"type": "BOOLEAN",
"description": "Press Enter after typing (default false)."
}
},
"required": [
"text"
]
}computer_press_keys
Press a keyboard key or hotkey combo, e.g. ['enter'], ['command','c'], ['command','space'], ['command','tab'].
{
"type": "OBJECT",
"properties": {
"keys": {
"type": "ARRAY",
"items": {
"type": "STRING"
},
"description": "Keys pressed together. Modifiers: command, option, ctrl, shift."
}
},
"required": [
"keys"
]
}computer_scroll
Scroll the mouse wheel. Positive amount scrolls up, negative scrolls down. Optionally give a normalized 0-1000 position to scroll over.
{
"type": "OBJECT",
"properties": {
"amount": {
"type": "INTEGER",
"description": "Scroll units. e.g. -5 scrolls down a bit."
},
"x": {
"type": "INTEGER",
"description": "Optional normalized x to hover before scrolling."
},
"y": {
"type": "INTEGER",
"description": "Optional normalized y to hover before scrolling."
}
},
"required": [
"amount"
]
}computer_drag
Drag with the left mouse button from one normalized 0-1000 coordinate to another (move windows, select text, sliders).
{
"type": "OBJECT",
"properties": {
"x1": {
"type": "INTEGER",
"description": "Start normalized x."
},
"y1": {
"type": "INTEGER",
"description": "Start normalized y."
},
"x2": {
"type": "INTEGER",
"description": "End normalized x."
},
"y2": {
"type": "INTEGER",
"description": "End normalized y."
}
},
"required": [
"x1",
"y1",
"x2",
"y2"
]
}read_ui_elements
Read the accessibility UI element tree of the frontmost application: buttons, fields, menus with their names and REAL pixel positions plus the screen size. More precise than a screenshot for finding exact click targets in native macOS apps.
{
"type": "OBJECT",
"properties": {}
}list_open_windows
List all open windows across ALL monitors and virtual desktops (Spaces): app name, window title, and which display each is on, plus windows on hidden desktops. Use this when the user mentions an app/window you can't see in the screenshot, or to find where their work actually is. Windows on other desktops can't be captured until brought forward — activate the app first, then look_at_screen.
{
"type": "OBJECT",
"properties": {}
}get_calendar_events
Read the user's calendar events (all accounts configured on this Mac: iCloud, Google, Exchange). Use for questions about schedule, meetings, availability, or upcoming events.
{
"type": "OBJECT",
"properties": {
"days_ahead": {
"type": "INTEGER",
"description": "How many days ahead to include (default 7)."
},
"days_back": {
"type": "INTEGER",
"description": "How many past days to include (default 0)."
}
}
}create_calendar_event
Create a new event in the user's default calendar. Always confirm title and time with the user before creating.
{
"type": "OBJECT",
"properties": {
"title": {
"type": "STRING",
"description": "Event title."
},
"start_iso": {
"type": "STRING",
"description": "Start time as 'YYYY-MM-DD HH:MM' (24h, local time)."
},
"duration_minutes": {
"type": "INTEGER",
"description": "Duration in minutes (default 60)."
},
"notes": {
"type": "STRING",
"description": "Optional notes/description."
}
},
"required": [
"title",
"start_iso"
]
}get_recent_emails
Read the most recent emails from the user's inbox (Apple Mail): sender, subject, unread status, and a short preview of each.
{
"type": "OBJECT",
"properties": {
"count": {
"type": "INTEGER",
"description": "Number of recent emails to fetch (default 10, max 25)."
}
}
}search_emails
Search recent inbox emails by sender or subject text (Apple Mail).
{
"type": "OBJECT",
"properties": {
"query": {
"type": "STRING",
"description": "Text to match against sender or subject."
},
"count": {
"type": "INTEGER",
"description": "Max results (default 8)."
}
},
"required": [
"query"
]
}list_3d_assets
List the 3D models already generated and saved on disk, newest first. Check here before generating: re-showing a saved model is instant and free, while generating costs a credit and a minute.
{
"type": "OBJECT",
"properties": {}
}show_3d_view
Switch which 3D surface is on screen. The 3D tab holds the spatial scene and every generated model side by side but shows ONE at a time, so this is the only way to change the selection. Pass 'spatial' (or 'scene') for the SVE scene, or a model's name to bring that model up. Use it whenever Vince says 'switch to', 'go back to', or 'show me the ... instead'.
{
"type": "OBJECT",
"properties": {
"target": {
"type": "STRING",
"description": "'spatial' for the SVE scene, or words naming a generated model, e.g. 'jet engine'."
}
},
"required": [
"target"
]
}show_3d_asset
Put an already-generated 3D model back on screen. Instant and free — no API call. Use whenever Vince refers to a model made earlier ('show me that drone again', 'bring back the jet engine'). Matches on title and on the prompt it was built from.
{
"type": "OBJECT",
"properties": {
"query": {
"type": "STRING",
"description": "Words identifying the saved model, e.g. 'jet engine'. Omit for the most recent."
},
"widget_id": {
"type": "STRING",
"description": "Optional stable card id."
}
}
}generate_spatial_3d_asset
Generate ONE photoreal, textured 3D object (.glb) from a description and mount it in the deck, orbitable by hand. Use this when Vince wants to SEE a real thing — a drone, an engine part, a piece of furniture, a prop. It returns INSTANTLY; the model renders into the card up to a minute later, so keep talking and never wait for it. This is NOT for diagrams: anything structural, labelled or editable — molecules, orbits, flowcharts, networks, anatomy, data — belongs in a 3D scene via dispatch_agent tier 'spatial', which is instant, free and can be updated afterwards. Each call costs an API credit, so one asset per ask.
{
"type": "OBJECT",
"properties": {
"prompt": {
"type": "STRING",
"description": "Vivid, concrete description of the single object, including material and finish. E.g. 'vintage brass astrolabe with engraved rings, studio lighting'. Describe one object, not a scene or an arrangement."
},
"widget_id": {
"type": "STRING",
"description": "Stable id for the subject, e.g. 'drone_model'. Reuse it to replace that card."
},
"title": {
"type": "STRING",
"description": "Card header, e.g. 'Recon Drone'."
}
},
"required": [
"prompt"
]
}create_skeleton_widget
Put a data card on screen. Use this for ANY answer carrying detail worth seeing — a quote, machine telemetry, a research briefing, a comparison. It returns INSTANTLY: the card appears as a loading skeleton and a background generator fills it in a few seconds later. So call it and keep talking; never wait, and never apologise for it loading. Reuse a widget_id to replace that card's contents.
{
"type": "OBJECT",
"properties": {
"widget_id": {
"type": "STRING",
"description": "Stable id for the subject, e.g. 'goog_quote' or 'sysmon'."
},
"title": {
"type": "STRING",
"description": "Card header, e.g. 'Alphabet Inc.'."
},
"query_context": {
"type": "STRING",
"description": "Everything the generator needs, in detail \u2014 it cannot see the conversation. Name the subject, every figure or section the card should carry, and any values you already know. E.g. 'Live GOOG quote: price, day change and percent, open/high/low, market cap, P/E, 52-week range, intraday trend chart, one-line sentiment.' Thin context makes a thin card."
}
},
"required": [
"widget_id",
"title",
"query_context"
]
}dismiss_widget
Remove one widget from the deck when Vince is done with it.
{
"type": "OBJECT",
"properties": {
"widget_id": {
"type": "STRING",
"description": "The widget to remove."
}
},
"required": [
"widget_id"
]
}clear_all_widgets
Empty the whole deck and return the orb to centre screen.
{
"type": "OBJECT",
"properties": {}
}dispatch_agent
Hand a complex, multi-step task to a background agent and return IMMEDIATELY. Use this whenever a request needs several tool calls, verification loops, or heavy OS work: multi-step macOS automation, long AppleScript/shell sequences, operating the GUI, or building and editing a 3D scene. Speak a one-line acknowledgement such as 'Working on that now.' in the SAME turn you call this. The result is delivered to you when the agent finishes and you announce it then. Do NOT use this for a single quick tool call or anything you can answer directly.
{
"type": "OBJECT",
"properties": {
"goal": {
"type": "STRING",
"description": "The complete task, self-contained. The agent runs without further input and cannot ask questions, so include every detail it needs."
},
"tier": {
"type": "STRING",
"enum": [
"os",
"spatial"
],
"description": "'os' for macOS automation, shell, AppleScript and GUI control. 'spatial' for building or editing 3D SVE scenes. Default 'os'."
}
},
"required": [
"goal"
]
}shutdown_friday
Gracefully shut down the Project FRIDAY assistant and exit the program. Use this when the user says goodbye, quit, exit, or asks you to turn off.
{
"type": "OBJECT",
"properties": {}
}Sentinel assistant: 10 tools
Expand a tool to inspect its current declaration and complete parameter schema. These are model-facing contracts from the source; runtime permission gates still apply.
get_nodes
Every node's last heartbeat: status, age in seconds, version, platform.
{
"type": "object",
"properties": {}
}get_telemetry
Latest CPU, memory, disk, thermal and power per node.
{
"type": "object",
"properties": {
"node_id": {
"type": "string",
"description": "Only this node; omit for all nodes."
}
}
}get_queue
Event queue depths, supervisor restarts and sentinel uptime.
{
"type": "object",
"properties": {}
}get_recent_events
Recent events from the bus, newest first.
{
"type": "object",
"properties": {
"type_glob": {
"type": "string",
"description": "Event type glob such as node.* (default *)."
},
"limit": {
"type": "integer",
"description": "1-50, default 20."
}
}
}get_audit
Recent audit entries: who changed what, newest first.
{
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "1-50, default 20."
}
}
}get_settings
Current settings with their source; secret values are masked.
{
"type": "object",
"properties": {}
}get_triage
Judged items: urgency, why, and what was decided.
{
"type": "object",
"properties": {
"urgency": {
"type": "string",
"description": "critical, high, normal or low."
},
"limit": {
"type": "integer",
"description": "1-50, default 20."
}
}
}get_digest
What is waiting in the digest pile — the answer to 'what did I miss?'.
{
"type": "object",
"properties": {}
}create_reminder
Put a reminder on the calendar. Use for anything with a time: 'remind me to call the bank tomorrow at 10'.
{
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "What to be reminded of."
},
"when_iso": {
"type": "string",
"description": "ISO 8601 local datetime, e.g. 2026-10-01T10:00:00. Resolve relative phrases against the current time given in your instructions."
},
"duration_minutes": {
"type": "integer",
"description": "1-1440, default 30."
},
"notes": {
"type": "string",
"description": "Optional detail for the event body."
}
},
"required": [
"title",
"when_iso"
]
}set_controls
Change the call mode, do-not-disturb, the monitor switches, or the sentinel's timing and retention parameters.
{
"type": "object",
"properties": {
"call_mode": {
"type": "string",
"description": "always, urgent_only or mute."
},
"dnd": {
"type": "boolean",
"description": "Do not disturb on/off."
},
"monitors": {
"type": "object",
"description": "Switches: email, calendar, jira \u2192 boolean.",
"properties": {
"email": {
"type": "boolean"
},
"calendar": {
"type": "boolean"
},
"jira": {
"type": "boolean"
}
}
},
"timing": {
"type": "object",
"description": "Operational parameters, applied live without a restart.",
"properties": {
"telemetry_interval_s": {
"type": "number",
"description": "1-3600 seconds."
},
"heartbeat_interval_s": {
"type": "number",
"description": "1-3600 seconds."
},
"retention_days": {
"type": "integer",
"description": "1-365 days of events and telemetry."
},
"chat_retention_days": {
"type": "integer",
"description": "1-3650 days of conversations."
}
}
}
}
}The incoming-call owner persona uses a restricted read-only subset; the message-taking persona has its own narrow tool. The portfolio website’s separate FRIDAY guide only exposes link navigation and does not inherit these project tools.
Configuration, routes & every setting
Source: friday/core/config.py; friday/sentinel/settings_registry.py; runtime_config.py
Bootstrap and precedence
Process configuration is loaded once from the repository .env, overlaid by the process environment. For dynamic keys the runtime then resolves vault → valid legacy environment value → registry default. An existing undecryptable vault value is reported as such instead of silently pretending it came from the environment.
Secrets are entered in Settings and AES-GCM encrypted. Other settings are stored as JSON; not every row is encrypted. Desktop pulls only settings declared for the desktop scope, overlays known fields, and stores a last-good cache with mode 0600. Changing bootstrap values or the bridge class list requires a process restart. Dashboard dynamic controls apply live; Desktop's scoped configuration is pulled at boot.
| Bootstrap variable | Default / purpose |
|---|---|
| FRIDAY_DATA_DIR | Repository data/; persistent state root |
| FRIDAY_NODE_ID | Hostname, or friday-node if unavailable |
| FRIDAY_LOG_LEVEL | INFO |
| FRIDAY_MASTER_KEY | Required on Sentinel; base64 key material of at least 32 bytes |
| FRIDAY_SENTINEL_BIND | 127.0.0.1:8770 |
| FRIDAY_TRUSTED_PROXY | false; enable only behind a trusted forwarding proxy |
| FRIDAY_DB_SYNCHRONOUS | FULL; NORMAL is the durability tradeoff option |
| FRIDAY_BRIDGES | Empty comma-separated class list |
| FRIDAY_SENTINEL_URL | Optional Desktop → Sentinel URL |
| FRIDAY_SENTINEL_TOKEN | Optional Desktop node credential |
| FRIDAY_TELEMETRY_INTERVAL | 15 seconds; legacy dynamic fallback |
| FRIDAY_HEARTBEAT_INTERVAL | 30 seconds; legacy dynamic fallback |
| FRIDAY_RETENTION_DAYS | 14; legacy dynamic fallback |
| GEMINI_API_KEY | Legacy model credential when absent from the vault |
| GEMINI_MODEL | Legacy Live model alias |
| FRIDAY_VOICE | Aoede; Desktop voice fallback |
| TRIPO_API_KEY | Optional generated-asset credential |
| FRIDAY_LLM_LIVE / AGENT_OS / AGENT_SPATIAL / WIDGET / TRIAGE / ASSISTANT / MINUTES | Per-role provider:model override; each suffix is prefixed FRIDAY_LLM_ |
An explicit FRIDAY_LLM_LIVE takes precedence over the legacy GEMINI_MODEL alias. The route defaults below are exactly those declared by this project checkout, not a promise that a provider/model is available to every API account.
Validation and save behavior
The registry defines string, URL, enum, boolean, integer, float, and string-list controls. Lists may be provided as comma-separated text. Range validators constrain numeric values. Time validators accept HH:MM strings; quiet hours may cross midnight. WhatsApp numbers are normalised to a phone-number JID. Phone-call destinations are normalised to international dial strings. adb templates validate allowed placeholders and require {number} for a nonempty dial template.
Settings edits are held until Save. The server validates the batch and returns per-field errors; the UI opens the relevant Advanced group and focuses the problem. Null values in the Settings API request remove overrides rather than storing the string "null". Controls use the same declared keys but apply immediately. Secret controls show only masked status; the form clears a typed secret after saving. Model routes and secrets are outside the chat assistant's control-write allowlist.
93 declared dynamic settings. This inventory is generated from the registry, including defaults, types, legacy environment aliases, scope, and validation. Unset means the registry default is null. Values shown here are declaration defaults, never values from a running installation.
llm
| Key / type | Default & constraints | Purpose & access |
|---|---|---|
llm.gemini_api_keystr | Unset | Google Gemini API key Secret: encrypted and masked · Node scope: desktop · Legacy env: GEMINI_API_KEY |
llm.routes.livestr | "gemini:gemini-3.1-flash-live-preview"provider:model | provider:model route for live Node scope: desktop · Legacy env: FRIDAY_LLM_LIVE |
llm.routes.agent_osstr | "gemini:gemini-3.8-flash"provider:model | provider:model route for agent_os Node scope: desktop · Legacy env: FRIDAY_LLM_AGENT_OS |
llm.routes.agent_spatialstr | "gemini:gemini-3.8-flash"provider:model | provider:model route for agent_spatial Node scope: desktop · Legacy env: FRIDAY_LLM_AGENT_SPATIAL |
llm.routes.widgetstr | "gemini:gemini-3.7-flash"provider:model | provider:model route for widget Node scope: desktop · Legacy env: FRIDAY_LLM_WIDGET |
llm.routes.triagestr | "gemini:gemini-3.7-flash"provider:model | provider:model route for triage Legacy env: FRIDAY_LLM_TRIAGE |
llm.routes.assistantstr | "gemini:gemini-3.7-flash"provider:model | provider:model route for assistant Legacy env: FRIDAY_LLM_ASSISTANT |
llm.routes.minutesstr | "gemini:gemini-3.8-flash"provider:model | provider:model route for minutes Legacy env: FRIDAY_LLM_MINUTES |
llm.tripo_api_keystr | Unset | Tripo3D API key (text-to-3D); optional Secret: encrypted and masked · Node scope: desktop · Legacy env: TRIPO_API_KEY |
desktop
| Key / type | Default & constraints | Purpose & access |
|---|---|---|
desktop.voicestr | "Aoede" | Gemini Live voice for the desktop (Aoede or Kore) Node scope: desktop · Legacy env: FRIDAY_VOICE |
sources
| Key / type | Default & constraints | Purpose & access |
|---|---|---|
sources.google.client_idstr | Unset | OAuth client id from your Google Cloud project |
sources.google.client_secretstr | Unset | OAuth client secret Secret: encrypted and masked |
sources.google.refresh_tokenstr | Unset | Written by 'friday-sentinel google-auth'; grants Gmail read+compose and Calendar events Secret: encrypted and masked |
sources.imap.hoststr | Unset | IMAP server for the personal mailbox |
sources.imap.portint | 993Range 1, 65535 | IMAP port |
sources.imap.userstr | Unset | IMAP username |
sources.imap.passwordstr | Unset | IMAP app password. There is deliberately no SMTP setting: FRIDAY cannot send. Secret: encrypted and masked |
sources.imap.drafts_folderstr | "Drafts" | Where drafts are appended (Gmail over IMAP uses '[Gmail]/Drafts') |
sources.jira.base_urlurl | Unset | Jira Cloud base URL, e.g. https://yourteam.atlassian.net |
sources.jira.emailstr | Unset | Atlassian account email |
sources.jira.api_tokenstr | Unset | Atlassian API token Secret: encrypted and masked |
sources.jira.jqlstr | "((assignee = currentUser() AND statusCategory != Done AND (priority in (Highest, High) OR status in (Blocked, \"On Hold\") OR flagged is not EMPTY)) OR (text ~ currentUser() AND updated >= -1d))" | What counts as worth watching. Edit freely; an invalid query shows on the Watching card rather than retrying. |
sources.email_interval_sfloat | 120.0Range 30, 3600 | Seconds between mailbox polls |
sources.calendar_interval_sfloat | 300.0Range 30, 3600 | Seconds between calendar polls |
sources.jira_interval_sfloat | 300.0Range 30, 3600 | Seconds between Jira polls |
sources.calendar_horizon_minint | 120Range 5, 1440 | How far ahead calendar events are surfaced, in minutes |
sources.max_backoff_sfloat | 900.0Range 60, 7200 | Longest gap between retries for a degraded source |
sources.meet_interval_sfloat | 300.0Range 60, 3600 | Seconds between looks at Drive for new Google Meet transcripts |
policy
| Key / type | Default & constraints | Purpose & access |
|---|---|---|
policy.viplist | Unset | Senders that always matter: full addresses, or @domain for a whole domain |
policy.mutelist | Unset | Senders that never matter; automated addresses are muted anyway |
policy.keywords_criticallist | ["outage", "production down", "p1", "sev1"] | Words in a title that mean critical, whoever sent it |
policy.meeting_lead_minint | 10Range 1, 240 | How long before a meeting it becomes urgent, in minutes |
policy.quiet_hoursstr | "22:00-07:00"_quiet_hours | When only a critical item may speak, as HH:MM-HH:MM; may cross midnight. Empty disables quiet hours. Empty value permitted |
policy.digest_timeslist | ["08:00", "13:00", "18:00"]_times | Local times the digest is delivered |
policy.draft_repliesbool | false | Write a suggested reply into Drafts for urgent mail. Nothing is ever sent. |
policy.repeat_window_minint | 30Range 0, 1440 | A second item on the same thread inside this window goes to the digest |
policy.model_budget_per_hourint | 60Range 0, 1000 | Most triage model calls per hour; past it the rules decide alone (0 = rules only) |
policy.realert_minint | 30Range 0, 240 | Minutes between reminders for an open critical alert (0 = no reminders) |
policy.realert_maxint | 2Range 0, 10 | Most reminders one alert may send |
policy.alert_ttl_hint | 24Range 1, 168 | Hours an unanswered alert stays open before it expires and frees its code |
minutes
| Key / type | Default & constraints | Purpose & access |
|---|---|---|
minutes.auto_calendarbool | true | Put your dated action items from meeting minutes on the calendar |
minutes.my_nameslist | Unset | Names that mean you in a transcript, such as Vince or VC: their action items are yours |
minutes.max_partsint | 6Range 1, 20 | Most WhatsApp messages one set of minutes may take |
minutes.max_transcript_charsint | 400000Range 10000, 2000000 | A longer transcript is cut before the minutes are written |
minutes.code_daysint | 7Range 1, 90 | Days a meeting keeps its #M code |
minutes.retention_daysint | 180Range 7, 3650 | Days meetings, their transcripts and minutes are kept |
minutes.media_daysint | 2Range 1, 30 | Days a recording that failed to become minutes is kept for a retry |
minutes.max_attemptsint | 3Range 1, 10 | Tries per step before a meeting is marked failed |
minutes.voice_note_max_sint | 120Range 10, 600 | WhatsApp audio up to this many seconds is a message to FRIDAY; longer is a meeting |
minutes.upload_max_mbint | 200Range 1, 2000 | Largest recording or transcript the dashboard accepts, in MB |
| Key / type | Default & constraints | Purpose & access |
|---|---|---|
whatsapp.tostr | Unset_whatsapp_to | The one number FRIDAY messages, with its country code. Stored normalised. Empty value permitted |
whatsapp.binarystr | "deploy/bin/friday-whatsapp" | Path to the friday-whatsapp sidecar built by deploy/build_sidecar.sh |
whatsapp.min_gap_sfloat | 3.0Range 0.5, 60 | Shortest gap between two messages; a burst is what gets an account banned |
whatsapp.max_per_hourint | 60Range 1, 500 | Most messages per rolling hour; over the cap they wait |
whatsapp.max_body_charsint | 3500Range 200, 4000 | Bodies are truncated to this, below WhatsApp's own ~4096 limit |
whatsapp.max_backoff_sfloat | 600.0Range 30, 3600 | Longest gap between retries for one message |
whatsapp.expire_after_hint | 24Range 1, 168 | A message undelivered for this long is expired and audited rather than sent late |
whatsapp.ping_timeout_sfloat | 20.0Range 5, 120 | No pong within this long means the sidecar is hung and is restarted |
whatsapp.tick_sfloat | 1.0Range 0.2, 10 | How often the sender loop looks for work |
whatsapp.stop_grace_sfloat | 2.0Range 0.5, 2.5 | How long the sidecar gets to exit on EOF before it is killed; must stay under the daemon's bridge stop timeout |
whatsapp.inboundbool | false | Read messages from whatsapp.to and answer them. Off leaves delivery working. |
whatsapp.max_inbound_charsint | 2000Range 100, 4000 | A longer message is truncated before the model sees it |
whatsapp.alertsbool | true | Send urgent alerts to WhatsApp with a code you can answer: ack, snooze 2h, not urgent, dismiss |
whatsapp.max_media_mbint | 50Range 1, 100 | Largest WhatsApp file FRIDAY downloads, in MB |
telephony
| Key / type | Default & constraints | Purpose & access |
|---|---|---|
telephony.adb_binarystr | "adb" | The adb executable on this host |
telephony.adb_serialstr | Unset | The phone's wireless adb address, host:port; empty uses the only connected device Empty value permitted |
telephony.audio.capturestr | Unset | ALSA device the phone's call audio arrives on, such as hw:1,0 Empty value permitted |
telephony.audio.playbackstr | Unset | ALSA device that feeds FRIDAY's voice to the phone, such as hw:1,0 Empty value permitted |
telephony.call_tostr | Unset_phone | The number FRIDAY rings for alerts; empty means whatsapp.to Empty value permitted |
telephony.dial_commandstr | "am start -a android.intent.action.CALL -d tel:{number}"_adb_template('number') | adb shell command that dials {number}; empty switches dialling off Empty value permitted |
telephony.answer_commandstr | "input keyevent KEYCODE_CALL"_adb_template() | adb shell command that answers a ringing call; empty never answers Empty value permitted |
telephony.hangup_commandstr | "input keyevent KEYCODE_ENDCALL"_adb_template() | adb shell command that ends the call Empty value permitted |
telephony.ring_timeout_sint | 20Range 10, 120 | Seconds an alert call rings before FRIDAY gives up and messages instead; keep it below your carrier's voicemail delay |
telephony.answer_after_ringsint | 4Range 1, 10 | Rings before FRIDAY answers an incoming call |
telephony.redial_minint | 0Range 0, 120 | Minutes before one redial of an unanswered alert call (0 = no redial) |
telephony.max_call_minint | 15Range 1, 60 | Longest call FRIDAY stays on |
telephony.poll_sfloat | 1.0Range 0.2, 5 | How often the phone is asked what the line is doing |
voice
| Key / type | Default & constraints | Purpose & access |
|---|---|---|
voice.enabledbool | true | Allow the dashboard to open a voice session with FRIDAY |
voice.nameenum | "Aoede"Choices: Aoede, Kore, Charon, Fenrir, Puck | The sentinel's Gemini Live voice (the desktop has its own) |
controls
| Key / type | Default & constraints | Purpose & access |
|---|---|---|
controls.call_modeenum | "urgent_only"Choices: always, urgent_only, mute | When escalations may place a phone call |
controls.dndbool | false | Do not disturb: suppress calls and pings |
controls.whatsappbool | false | Run the WhatsApp bridge. Off releases the session so pairing can run. |
controls.telephonybool | false | Let FRIDAY place and answer phone calls through the paired phone |
controls.triagebool | false | Score and act on watched items; off means nothing is judged |
controls.monitors.emailbool | false | Watch the mailbox (read + drafts) |
controls.monitors.calendarbool | false | Watch the calendar and guard meetings |
controls.monitors.jirabool | false | Watch Jira for blockers (read-only) |
controls.monitors.meetbool | false | Turn Google Meet transcripts in Drive into minutes; re-run google-auth first to grant Drive read access |
sentinel
| Key / type | Default & constraints | Purpose & access |
|---|---|---|
sentinel.timezonestr | "" | IANA zone for quiet hours, digests and daily counts; empty uses the host's Empty value permitted |
sentinel.telemetry_interval_sfloat | 15.0Range 1, 3600 | Seconds between telemetry samples on the sentinel host Legacy env: FRIDAY_TELEMETRY_INTERVAL |
sentinel.heartbeat_interval_sfloat | 30.0Range 1, 3600 | Seconds between the sentinel's own heartbeats (also the watchdog ping) Legacy env: FRIDAY_HEARTBEAT_INTERVAL |
sentinel.retention_daysint | 14Range 1, 365 | Days of telemetry and finished events to keep Legacy env: FRIDAY_RETENTION_DAYS |
sentinel.chat_retention_daysint | 90Range 1, 3650 | Days an idle assistant conversation is kept |
Persistence, migrations & recovery
Source: friday/core/storage.py; friday/core/vault.py; friday/sentinel/bus.py
Store and queue mechanics
The synchronous Store owns one SQLite connection. AsyncStore serialises calls onto a dedicated worker thread. SQLite 3.35+ is required for UPDATE…RETURNING. WAL journaling and FULL synchronisation are the defaults; NORMAL reduces synchronisation work but may lose recent commits after power loss. This is a durability setting, not a replacement for backups.
Forward-only schema migrations run statements inside a transaction with the schema-version update. Interrupted processing is returned to pending on startup. The event queue orders eligible work by priority and time, increments claim attempts, and persists errors. Event publication, node heartbeat upserts, telemetry, configuration, sessions, audit, conversations, watched items, outbox, escalation, meetings, and calls all use the same persistent store.
Data families
| Tables / state | Role |
|---|---|
| kv, heartbeats, events, telemetry | Shared node state, event delivery, and observability |
| settings, users, sessions, node_tokens, audit | Configuration, authentication, and accountability |
| conversations, messages | Text and voice history with channel metadata |
| watch_items | Monitor deduplication, triage verdict, decision claim, digest status |
| whatsapp_outbox, whatsapp_inbound | Durable outbound delivery and inbound message deduplication |
| escalations, whatsapp_refs, triage_feedback | Alert lifecycle, quoted-reply resolution, labelled feedback |
| meetings, meeting_actions, calls | Meeting pipeline, action/calendar bookkeeping, and call records |
Desktop state is also local: memory facts, biometric profiles, persisted scene graphs, generated asset indexes and GLBs, and the config cache live beneath the configured data directory. Do not assume every Desktop artifact is a row in sentinel.db.
Vault cryptography and session storage
The master key decodes to at least 32 bytes. HKDF-SHA256 derives a 32-byte AES-GCM key with the friday-vault-v1 context. Encryption uses a new 12-byte nonce; the setting key is associated data. Stored ciphertext tokens are versioned (v1:nonce:ciphertext). Moving a ciphertext to another setting name fails authentication. Losing or replacing the master key makes existing secrets unreadable.
Passwords use scrypt with N=32768, r=8, p=1, a random 16-byte salt, and a 32-byte derived key. Verification uses constant-time comparison; unknown users still incur a hash verification. Session and node-token plaintext is random and only its SHA-256 hash is stored. The session cookie is friday_session, HttpOnly and SameSite=Lax, Secure over correctly detected HTTPS. Session TTL defaults to 30 days. Node tokens use the fn_ prefix and can be revoked independently.
Backup and recovery
For a simple consistent backup, stop Sentinel cleanly before copying its persistent data and store the master key separately in a secure backup. For a live backup, use SQLite's backup mechanism rather than copying only sentinel.db while WAL writes continue. Include the WhatsApp sidecar's pairing/session state if you need to preserve that pairing, and the Desktop data you want to retain. Protect plaintext config caches and biometric files too.
Restore the database with its original master key. Do not regenerate the key to repair an undecryptable vault. Confirm health, node authentication, source credentials, and channel pairing before re-enabling unattended alerts. Schema migrations are forward-only; keep a pre-upgrade backup when changing versions.
Exact migration definitions
The following SQL is generated from the application's migration declarations. It documents schema evolution; do not manually execute these statements against a running installation. FRIDAY applies them itself.
Schema migration 1
CREATE TABLE kv ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at REAL NOT NULL);
CREATE TABLE heartbeats ( node_id TEXT PRIMARY KEY, last_seen REAL NOT NULL, status TEXT NOT NULL, meta TEXT NOT NULL);
CREATE TABLE events ( id TEXT PRIMARY KEY, ts REAL NOT NULL, source TEXT NOT NULL, type TEXT NOT NULL, payload TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, claimed_at REAL, processed_at REAL, error TEXT);
CREATE INDEX events_status_ts ON events(status, priority DESC, ts);
CREATE TABLE telemetry ( ts REAL NOT NULL, node_id TEXT NOT NULL, snapshot TEXT NOT NULL);
CREATE INDEX telemetry_node_ts ON telemetry(node_id, ts DESC);Schema migration 2
CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, secret INTEGER NOT NULL DEFAULT 0, updated_at REAL NOT NULL, updated_by TEXT NOT NULL);
CREATE TABLE users ( username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, created_at REAL NOT NULL, password_changed_at REAL NOT NULL);
CREATE TABLE sessions ( id TEXT PRIMARY KEY, username TEXT NOT NULL, created_at REAL NOT NULL, expires_at REAL NOT NULL, last_seen REAL NOT NULL, user_agent TEXT, ip TEXT);
CREATE INDEX sessions_expires ON sessions(expires_at);
CREATE TABLE node_tokens ( id TEXT PRIMARY KEY, name TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, created_at REAL NOT NULL, last_used REAL, revoked_at REAL);
CREATE TABLE audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, target TEXT, detail TEXT NOT NULL);
CREATE INDEX audit_ts ON audit(ts DESC);Schema migration 3
CREATE TABLE conversations ( id TEXT PRIMARY KEY, title TEXT NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL);
CREATE INDEX conversations_updated ON conversations(updated_at DESC);
CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL, seq INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, tool_name TEXT, tool_args TEXT, tool_result TEXT, status TEXT NOT NULL DEFAULT 'complete', ts REAL NOT NULL);
CREATE UNIQUE INDEX messages_conv_seq ON messages(conversation_id, seq);Schema migration 4
ALTER TABLE messages ADD COLUMN via TEXT NOT NULL DEFAULT 'text';Schema migration 5
CREATE TABLE watch_items ( id TEXT PRIMARY KEY, source TEXT NOT NULL, external_id TEXT NOT NULL, title TEXT NOT NULL, snippet TEXT NOT NULL, who TEXT NOT NULL, url TEXT NOT NULL, ts REAL NOT NULL, first_seen REAL NOT NULL, meta TEXT NOT NULL);
CREATE INDEX watch_items_seen ON watch_items(first_seen DESC);
CREATE INDEX watch_items_source_ts ON watch_items(source, ts DESC);Schema migration 6
ALTER TABLE watch_items ADD COLUMN urgency TEXT NOT NULL DEFAULT '';
ALTER TABLE watch_items ADD COLUMN category TEXT NOT NULL DEFAULT '';
ALTER TABLE watch_items ADD COLUMN reason TEXT NOT NULL DEFAULT '';
ALTER TABLE watch_items ADD COLUMN scored_by TEXT NOT NULL DEFAULT '';
ALTER TABLE watch_items ADD COLUMN decision TEXT NOT NULL DEFAULT '';
ALTER TABLE watch_items ADD COLUMN suppressed_by TEXT NOT NULL DEFAULT '';
ALTER TABLE watch_items ADD COLUMN digest_state TEXT NOT NULL DEFAULT '';
ALTER TABLE watch_items ADD COLUMN thread_key TEXT NOT NULL DEFAULT '';
CREATE INDEX watch_items_digest ON watch_items(digest_state, first_seen DESC);
CREATE INDEX watch_items_thread ON watch_items(thread_key, first_seen DESC);Schema migration 7
CREATE TABLE whatsapp_outbox ( id TEXT PRIMARY KEY, body TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 0, item_id TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at REAL NOT NULL, created_at REAL NOT NULL, sent_at REAL NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', message_id TEXT NOT NULL DEFAULT '');
CREATE INDEX whatsapp_outbox_due ON whatsapp_outbox(state, priority DESC, next_attempt_at);Schema migration 8
CREATE TABLE whatsapp_inbound (id TEXT PRIMARY KEY, ts REAL NOT NULL);
CREATE INDEX whatsapp_inbound_ts ON whatsapp_inbound(ts DESC);Schema migration 9
CREATE TABLE escalations ( id TEXT PRIMARY KEY, code INTEGER NOT NULL, state TEXT NOT NULL DEFAULT 'open', urgency TEXT NOT NULL, title TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', sender TEXT NOT NULL DEFAULT '', category TEXT NOT NULL DEFAULT '', scored_by TEXT NOT NULL DEFAULT '', thread_key TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL, updated_at REAL NOT NULL, wake_at REAL NOT NULL DEFAULT 0, realerts INTEGER NOT NULL DEFAULT 0, acted_by TEXT NOT NULL DEFAULT '', acted_via TEXT NOT NULL DEFAULT '', held INTEGER NOT NULL DEFAULT 0);
CREATE UNIQUE INDEX escalations_live_code ON escalations(code) WHERE state IN ('open', 'snoozed');
CREATE INDEX escalations_due ON escalations(state, wake_at);
CREATE TABLE whatsapp_refs ( outbox_id TEXT PRIMARY KEY, kind TEXT NOT NULL, ref_id TEXT NOT NULL, created_at REAL NOT NULL);
CREATE INDEX whatsapp_outbox_message ON whatsapp_outbox(message_id);
CREATE TABLE triage_feedback ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts REAL NOT NULL, item_id TEXT NOT NULL, label TEXT NOT NULL, source TEXT NOT NULL, sender TEXT NOT NULL, thread_key TEXT NOT NULL, category TEXT NOT NULL, urgency TEXT NOT NULL, scored_by TEXT NOT NULL, actor TEXT NOT NULL);
CREATE INDEX triage_feedback_ts ON triage_feedback(ts DESC);Schema migration 10
CREATE TABLE meetings ( id TEXT PRIMARY KEY, code INTEGER, source TEXT NOT NULL, external_id TEXT NOT NULL, title TEXT NOT NULL, occurred_at REAL NOT NULL, duration_s REAL NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'received', attempts INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '', media_path TEXT NOT NULL DEFAULT '', mime TEXT NOT NULL DEFAULT '', transcript TEXT NOT NULL DEFAULT '', minutes TEXT NOT NULL DEFAULT '', created_at REAL NOT NULL, updated_at REAL NOT NULL, delivered_at REAL NOT NULL DEFAULT 0, next_at REAL NOT NULL DEFAULT 0, UNIQUE (source, external_id));
CREATE INDEX meetings_state ON meetings(state, next_at);
CREATE UNIQUE INDEX meetings_live_code ON meetings(code) WHERE code IS NOT NULL;
CREATE TABLE meeting_actions ( meeting_id TEXT NOT NULL, idx INTEGER NOT NULL, text TEXT NOT NULL, owner TEXT NOT NULL DEFAULT '', due_at REAL NOT NULL DEFAULT 0, mine INTEGER NOT NULL DEFAULT 0, calendar_event_id TEXT NOT NULL DEFAULT '', PRIMARY KEY (meeting_id, idx));
CREATE TABLE calls ( id TEXT PRIMARY KEY, direction TEXT NOT NULL, number TEXT NOT NULL DEFAULT '', persona TEXT NOT NULL DEFAULT '', item_id TEXT NOT NULL DEFAULT '', state TEXT NOT NULL, outcome TEXT NOT NULL DEFAULT '', started_at REAL NOT NULL, answered_at REAL NOT NULL DEFAULT 0, ended_at REAL NOT NULL DEFAULT 0, meeting_id TEXT NOT NULL DEFAULT '');CLI, development & troubleshooting
Source: deploy/README.md; readme.md; friday/sentinel/cli.py; tests/
Command-line entry points
The installed entry points are friday-desktop and friday-sentinel. Their module equivalents are python -m friday.desktop and python -m friday.sentinel; use the virtual environment's Python. Desktop can also run its browser-facing hub with python -m friday.desktop.hub.
| Sentinel command | Purpose |
|---|---|
| run, or no subcommand | Start the daemon |
| keygen | Print a new master-key assignment; first setup only |
| user set-password NAME | Create/update dashboard credentials; interactive confirmation |
| user set-password NAME --password-stdin | Read a password from standard input |
| token create NAME | Issue a node token, plaintext shown once |
| token list | List issued node-token metadata |
| token revoke ID | Revoke a node token |
| google-auth | Authorise Gmail, Calendar, and Drive scopes and store refresh token |
| google-auth --print-only | Authorise on a browser-capable machine and print the token |
| google-auth --revoke | Forget the stored Google refresh token |
| whatsapp-pair | Pair the linked-device sidecar; stop the running bridge first |
| whatsapp-pair --reset | Forget the session and pair afresh |
| whatsapp-pair --timeout SECONDS | Adjust QR scan window (default 120 seconds) |
| whatsapp-pair --binary PATH | Override the sidecar binary for pairing |
| minutes add PATH --title TITLE --date ISO_DATETIME | Queue audio/transcript; optional title/date |
Bootstrap commands use the SQLite store directly and audit as the CLI actor. A clean shutdown, persistent data directory, and matching master key are required for upgrades and service relocation. Existing installation commands are in the setup and deployment chapters above.
Development and dependencies
Install .[desktop,dev] on the Mac or .[sentinel,dev] on the server for test tooling. Core dependencies are python-dotenv, psutil, aiohttp, google-genai, and cryptography. Desktop adds the audio, image, macOS bridge, GUI, and websocket dependencies declared in pyproject.toml. The sentinel extra deliberately excludes macOS-only libraries.
The Sentinel dashboard is vanilla ES modules with a committed Tailwind stylesheet. After changing dashboard class names, run deploy/build_css.sh; it downloads the pinned standalone Tailwind binary into the local cache on first use. This is the application dashboard build, distinct from this static documentation website, which needs no frontend build.
deploy/build_sidecar.sh builds the Go WhatsApp sidecar for the Mac and Raspberry Pi targets. The destination Pi does not need the Go toolchain. Rebuild and deploy the current sidecar when using quoted alerts, media ingestion, or identity-resolution changes; an old binary may lack the corresponding protocol features.
Troubleshooting
| Symptom | What to check |
|---|---|
| Desktop cannot import audio/macOS libraries | Use macOS and the desktop extra; grant the relevant OS permissions and satisfy PyAudio/PyWebView platform dependencies |
| Sentinel refuses to start due to the master key | Confirm the original key is present, valid base64, and decodes to at least 32 bytes; generate only for a new vault |
| Settings show undecryptable | Recover the original master key or restore the matching backup; do not overwrite it with a new key |
| No watched items | Configure source credentials and enable the corresponding monitor; then check Watching/Activity |
| Watching works but no decisions | Enable Controls → Triage; check the model route/key and the policy budget |
| Important item did not ring | Inspect Triage's suppression reason, call mode, DND, quiet hours, meeting guard, and repeat window; then verify telephony hardware and alert state |
| Google source says needs reauth | Repeat Google consent; for Meet, enable Drive API and grant the additional scope |
| Meet document is missing | Only owned transcripts are ingested, with a one-day initial lookback; shared or unexportable documents require an upload |
| WhatsApp says needs pairing / session locked | Switch the bridge off, pair on the sending host, ensure only one process owns its session, then restart/enable |
| WhatsApp message is pending | Check sidecar health, pacing/hourly cap, phone/session connectivity, retries, and expiry |
| Alert replies are ignored | Enable whatsapp.inbound, send from whatsapp.to, and use an exact command; add the alert code or quote it when several are open |
| Voice cannot start in the browser | Use localhost or HTTPS, allow mic access, enable voice.enabled, verify Gemini configuration and WebSocket proxying; close duplicate sessions |
| Phone refuses dial/answer | Verify adb connectivity, Android permissions/ROM behavior, command templates, and ALSA devices; use the reported WhatsApp fallback reason |
| Minutes failed | Inspect Meetings pipeline state, format/size, Gemini access, retry attempts, and retained media; retry from dashboard or queue the same file again |
| Newly deployed dashboard appears stale | Confirm updated static files and no-cache response headers; rebuild Tailwind if classes changed |
| A sensor shows no value | Unsupported telemetry probes return null; missing thermal/battery sensors are expected on some hosts |
| Model route fails | Configure a model actually available to your API account; declared defaults are not a provider-availability guarantee |
Limits and extension points
The event dispatcher is sequential; slow handlers can delay later events. Text assistant and Desktop agents have finite step budgets. WhatsApp is unofficial and subject to account restrictions. Telephony depends on external hardware, adb permissions, and carrier behavior. SVE uses a single active viewport and projected hand input; VR/AR, per-object hand rotation/scale, and mouse object dragging are not implemented by the documented interface.
A bridge implements start(bus, ctx) and stop(), publishes inbound events, and subscribes to command patterns. Add the class to FRIDAY_BRIDGES and restart. A new monitor uses a narrowly scoped source capability and emits normalised watch items. New SVE primitives require both Python validation and renderer support; external renderers consume the same scene/op messages and emit user actions. Extend the settings registry with types, validation, labels, units, grouping, and node scopes rather than adding unrelated hard-coded configuration paths.
Repository map
friday-ai-assistant/
├── pyproject.toml # one distribution "friday"; extras: desktop, sentinel, dev
├── .env.template # host bootstrap (master key, bind, data dir) + legacy values
├── SVE.md # Spatial Visualization Engine specification
├── friday/
│ ├── core/ # shared, platform-neutral
│ │ ├── config.py # Settings from env + .env; per-role LLM routes
│ │ ├── platform.py # OS/arch/Pi/distro probes
│ │ ├── storage.py # SQLite/WAL Store + AsyncStore: kv, heartbeats, events, telemetry, settings, users, sessions, tokens, audit
│ │ ├── vault.py # AES-GCM secrets under HKDF(FRIDAY_MASTER_KEY); setting key as AAD
│ │ ├── events.py # Event envelope, Heartbeat, TelemetrySnapshot schemas
│ │ ├── telemetry.py # sensor-tolerant collector
│ │ ├── logsetup.py # stdout logging, journald-aware
│ │ ├── live/ # Gemini Live: session, events, audio transports
│ │ └── llm/ # routing, provider protocol + conversation API (Message, Chunk, stream), Gemini adapter
│ ├── desktop/ # the macOS node
│ │ ├── hub.py # Async hub: audio, WebSocket, Live session, widget deck
│ │ ├── app.py # PyWebView desktop shell + process lifecycle
│ │ ├── agents.py # Background agent tiers (os / spatial) and their tool loop
│ │ ├── widget_generator.py # Card HTML synthesis + output sanitiser
│ │ ├── asset_generator.py # Tripo3D text-to-3D
│ │ ├── sentinel_client.py # heartbeat poster + config pull from the sentinel
│ │ ├── alerts.py # /ws subscriber: alert cards and the HUD speech queue
│ │ ├── config_pull.py # overlay sentinel config onto Settings; 0600 cache; .env fallback
│ │ ├── sentry_vision.py # Quartz multi-monitor capture & coordinate tracking
│ │ ├── sentry_action.py # CGEvent mouse/keyboard automation & click mapping
│ │ ├── sentry_exec.py # Shell & osascript execution
│ │ ├── sentry_recognition.py # Face (YuNet+SFace) & voice (MFCC) biometrics
│ │ ├── sentry_scene.py # SVE scene graph manager, validation, persistence
│ │ ├── sentry_personal.py # EventKit calendars & Apple Mail
│ │ ├── sentry_web.py # Async webpage reader
│ │ ├── models/ # YuNet + SFace ONNX weights
│ │ └── web_gui/ # index.html, style.css, app.js, orb.js, sve.js, gestures.js, vendor/
│ └── sentinel/ # the headless node
│ ├── daemon.py # boot / supervise / ordered shutdown, sd_notify
│ ├── bus.py # durable dispatcher over the store
│ ├── handlers.py # Handler protocol + heartbeat / telemetry / log handlers
│ ├── api.py # aiohttp: /health /telemetry /nodes /events /ws
│ ├── web.py # /auth/* /api/settings /api/tokens /api/audit /api/events /api/telemetry /api/watch /api/chat /config + shell
│ ├── audit.py # audit row + audit.entry event
│ ├── voice.py # /voice/ws bridge: Live session ↔ browser audio
│ ├── assistant.py # tool set and streaming turn loop
│ ├── services.py # Services bundle handed to every handler
│ ├── principals.py # who is asking: session cookie (user) or bearer token (node)
│ ├── auth.py # scrypt passwords, hashed sessions, node tokens, login lockout
│ ├── settings_registry.py # declared shape of every dynamic setting
│ ├── runtime_config.py # vault → legacy .env → default; publishes config.changed
│ ├── cli.py # friday-sentinel: run, keygen, user set-password, token create/list/revoke, google-auth
│ ├── dashboard/ # index.html, app.js, api.js, socket.js, ui.js, md.js, fields.js, form.js, chat.js, views/ (incl. voice.js), tailwind.{src.css,config.js,css}
│ ├── monitors.py # telemetry sampler, self-heartbeat, housekeeping
│ ├── sources/ # least-privilege capabilities: gmail, imap, calendar, jira
│ ├── watch.py # Monitor protocol, WatchRunner, the three monitors
│ ├── triage/ # rules, model, policy, handler, drafts, digest
│ ├── bridges/whatsapp/ # jid, protocol, sidecar, outbox sender, inbound gate, turns
│ └── bridges/ # Bridge protocol + FRIDAY_BRIDGES loader
│ └── webassets/ # shared browser assets: orb.js + three.module.min.js (served at /shared)
├── sidecar/whatsapp/ # Go: the only process that speaks WhatsApp
├── tests/ # pytest: core, sentinel, desktop import smoke, boundary guard
├── deploy/ # systemd unit, launchd plist, setup_remote.sh, build_css.sh, README
├── docs/superpowers/ # design spec and implementation plan
└── data/ # ALL runtime state, git-ignored (FRIDAY_DATA_DIR)Generated at runtime, not in the repo. Everything below lives under data/ (or FRIDAY_DATA_DIR), is git-ignored, and is created on first run:
| File | Holds |
|---|---|
sentinel.db (+ -wal, -shm) | The sentinel's SQLite store: heartbeats, event queue, telemetry, encrypted settings, user, sessions, node tokens, audit |
config-cache.json | Desktop's last good config pull from the sentinel (mode 0600) |
friday_memory.json | Persistent facts FRIDAY has been asked to remember |
friday_profiles.json | Face and voice embeddings for identity recognition |
friday_scenes.json | Saved 3D scene graphs |
friday_assets.json, generated_assets/ | Generated 3D model index and .glb files |
friday_history.jsonl | Local interaction log |
.webview/ | WKWebView data store (camera/mic permission grants) |
Test coverage
.venv/bin/pytestcore and sentinel are covered end to end: SQLite pragmas, crash recovery and the v1 → v2 migration, the event queue's claim/retry/park lifecycle, every telemetry probe with its sensor missing, the vault (wrong key, wrong setting key, tampered ciphertext), settings validation and vault → env → default precedence, scrypt/session/token/lockout logic, every dashboard route (CSRF header, Origin check, cookie flags, masked secrets, audit trail), the CLI, the desktop's config pull with cache and .env fallback, the daemon's full boot → SIGTERM → clean-exit path, the assistant loop against a scripted provider (tool round-trips, step cap, timeouts, prompt-injection, disconnects), NDJSON streaming through the aiohttp test client, a dashboard build check that fails when tailwind.css is stale or a module does not parse, the dashboard's modules driven in node (dirty tracking and rebasing, controls by type, once-per-frame streaming and follow-the-tail, markdown tables over every prefix of a streamed reply, the orb re-attaching to a new stage and never mounting after the page is left), the Live engine against a scripted fake client (turn loop, interruption, tool answers including a raising tool, GoAway rotation, resume-handle rules, backoff), the voice bridge end to end (auth, frame routing, transcript persistence), the source capabilities against fake Google, Jira and IMAP servers (HTML-only mail, RFC 2047 headers, all-day events, oversized bodies), the two send guardrails, the watch runner's dedupe, switch, backoff and degrade/recover paths, the rule table and its ordering promises, the forced function call with every unusable answer it can return, the policy engine over every combination of urgency, call mode, DND, quiet hours and meeting, quiet-hours spans that cross midnight, a replayed item proving one call and one draft, the digest schedule across a restart, the HUD speech queue waiting for a turn to finish, the outbox's dedupe, ordering, backoff and expiry, the line codec against blank, oversized and non-object lines, a real child process that crashes, hangs, prints garbage and holds a lock, the pacing under a fake clock, the guarantee that a payload carrying its own recipient still goes to the configured number, the sender resolver over hidden @lid addresses with and without a mapping, the gate's authorisation and message-id deduplication, two messages proving turns run sequentially, the markdown translation table, and create_reminder refusing a past or unparseable time. tests/test_boundaries.py imports every core/sentinel module in a subprocess where Quartz, pyaudio, cv2, webview, EventKit, numpy and friends are blocked — the guarantee that the sentinel really does run on a headless Linux box. Desktop modules get an import smoke that is skipped where the desktop extra is absent.
The actual interfaces.
These development screenshots show the Desktop workspace and Sentinel dashboard. Displayed records and telemetry are captured examples, not live information.