An always-on assistant that lives on a Mac mini — her own face, her own voice, her own inbox, and a brain that decides for itself whether to answer locally or reach for the cloud.
How she was coded · How she works · What she runs on
↓ scroll
The design constraint that shaped everything: Lucille is a voice-first appliance, not an app you open. She boots when the Mac mini boots, sits full-screen on a small display, listens for her name, and answers out loud in one to three sentences. Every architectural decision downstream — routing, streaming speech, barge-in, the trailing-text filter — exists because a reply that is read aloud has completely different requirements than a reply you read.
/Applications and added to Login Items.
SwiftUI + AppKit, macOS 13+, arm64. LSUIElement menu-bar companion, a global
⌥Space hotkey via Carbon event taps, and an app-sandbox entitlement set limited to mic,
network client/server, and Application Support.
Apple's SFSpeechRecognizer with requiresOnDeviceRecognition = true. Two separate
recognizers: one always-on wake listener matching "lucille", one command capture with a 0.8 s silence
window and a 12 s ceiling.
Primary path is a local neural TTS server on :8123 (Kokoro via mlx-audio), synthesized
sentence-by-sentence. Fallback is AVSpeechSynthesizer with a Premium system voice, so she
always has a voice.
qwen2.5:7b-instruct on localhost:11434 through the OpenAI-compatible route. Used
for chit-chat, holding phrases, and email triage — anything where paying a cloud round-trip would be
absurd.
Anthropic Messages API with adaptive thinking, high effort, a 16k output ceiling, and a 300 s turn
timeout. Server-side web_search/web_fetch plus client-side tools and optional
hosted MCP servers.
Two launchd-managed local services: TTS on :8123 (~/.lucille-tts) and a Google
bridge on :8770 (~/.lucille-google) fronting Gmail, Calendar, Drive, Contacts,
and the client-alert renderer.
One AssistantViewModel on the main actor owns all state; every service is a leaf it wires
together with closures and Combine publishers. State changes fan out to the face, the menu bar, and any
connected phone in the same assignment.
Fig. 1 — every box is a file; every arrow is a closure or an HTTP call
The routing heuristic is the single most consequential piece of logic in the app — it decides cost, latency,
and whether she can use tools at all. It was later extracted into a pure RouteDecision function
precisely so it could be unit-tested in isolation.
Fig. 2 — chooseRoute(for:mode:), drawn as it executes
// Hybrid policy: keep trivial chit-chat on the fast local model, and // send anything substantive to Claude. Default is cloud — local is // the exception, not the rule. private func chooseRoute(for prompt: String, mode: RoutingMode) -> RoutingMode { if mode != .auto { return mode } let lower = prompt.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let wordCount = lower.split(separator: " ").count if trivial.contains(lower) { return .local } // Very short, content-free utterances stay local — unless they're a // question (starts with a question word), which goes to Claude. let firstWord = lower.split(separator: " ").first.map(String.init) ?? "" if wordCount <= 2 && !questionWords.contains(firstWord) { return .local } return .cloud }
Fig. 3 — one spoken answer can hide fourteen model turns
Fig. 4 — the proactive path: an email becomes a drafted client alert
Fig. 5 — the dark web path: gate first, red eyes second, findings third
A cloud turn with thinking and web search can run twenty seconds. Silence for twenty seconds reads as broken. So the cloud task is kicked off first, and while it runs the local model generates a two-to-six word holding phrase that is spoken immediately — "Checking your calendar." The acknowledgment is deliberately never written to memory, so it can't pollute later context.
Fig. 6 — perceived latency ≈ 0.4 s, actual latency up to 20 s
The tool array is assembled per request. Anthropic's own tools run server-side; the rest execute in Swift,
mostly by forwarding to the Google bridge on :8770. Hosted MCP servers can be added by editing a
plist — every server is auto-paired with a toolset, with an optional allowlist so a destructive tool never
quietly enters a voice assistant's reach.
| Tool | Runs | What it's for |
|---|---|---|
| web_search · web_fetch | Anthropic | Live facts and reading primary sources; spins up a code-execution container |
| get_current_datetime | Swift | She is forbidden from ever guessing the time |
| open_application | Swift · NSWorkspace | "Open Safari" — direct Mac control |
| send_email · search_email | Bridge :8770 | Sends as Tanner or as herself; identity is an explicit enum, not a guess |
| get_calendar · create_calendar_event | Bridge :8770 | Attendee invites and Google Meet links; she may only claim success on confirmation |
| search_drive · find_contact | Bridge :8770 | Resolves a name to an address before any email is sent |
| client_alert_style_guide | Bridge :8770 | Mandatory first call when drafting — style guide, cross-link library, JSON schema |
| create_client_alert | Bridge :8770 | Renders a Mayer Brown-styled .docx and emails it, always CC'ing Tanner |
| abyssal_search | Abyssal MCP | Dark web and leak-site research; the only tool that turns her eyes red |
| abyssal_victims · abyssal_monitor | Abyssal MCP | Victim-store lookups and watchlist status, read-only by allowlist |
| perplexity_search · perplexity_deep_research | Perplexity MCP | Cited research through Sonar when a question needs breadth, not one page |
| mcp_toolset · N servers | Hosted MCP | Optional, plist-configured, per-tool allowlist |
The system prompt does a surprising amount of the engineering here: it forces the style guide to be loaded first, forbids stopping mid-task to ask permission, and bans claiming an action succeeded without a tool result confirming it.
“Draft a client alert on the Delta Dental NYDFS consent order” is the single request that pushed this whole app from toy to tool. It is also the request that broke the most, because it needs research, style fidelity, deterministic document rendering and email — in one unbroken turn, while she is talking to you.
Fig. 7 — five calls, one turn, no stopping to ask permission
The style guide, the cross-link library of prior Mayer Brown publications, and the alert JSON schema live behind an MCP-style Node server and are fetched at call time. Nothing about his house style is baked into the app binary, so the writing rules can change without touching a line of Swift.
The model produces structured content — kicker, headline, authors, body runs with embedded links, contacts
— and the Node docx library renders it. Layout is never left to the model, so every alert comes
out of the same template with live Word hyperlinks rather than pasted URLs.
Output is framed as a review-ready draft for attorney, marketing and clearance review — no byline, no claim of clearance. She sends from her own address by default so nothing reaches a client looking like it came from him unreviewed.
max_tokens mid-work, so create_client_alert was never reached. The ceiling is
now 16,000 — spent on thinking and orchestration, while the spoken reply stays capped at 1–3 sentences.
web_search runs server-side code execution, which spins
up a container. Once a turn uses it, every follow-up request must reference that container by id. Research
and document creation span a turn boundary, so the id has to be captured and threaded back — this was the
exact bug that broke client alerts.
maxTurns is 14 specifically because multi-step
skills like this one need room to search, read, draft and render without the loop cutting out.
The same substrate carries the staged NDAA program — trackers, comparison charts, one-pagers, implementation updates — because the skill is a set of resources plus two tools, not a hardcoded feature. Adding a new practice area means adding resources, not shipping a new build of the app.
Abyssal is the other half of this stack: a multi-agent dark web research tool that answers a plain-English question by planning subqueries, fanning them across Tor, and returning structured findings instead of pages. Wiring it into Lucille as an MCP server gives her a capability no consumer assistant has — and that deserved a visible tell. Red visor means a Tor circuit is open somewhere on her behalf.
Fig. 8 — the Abyssal query lifecycle: fan out wide, extract with a model, reconcile once
An LLM planner turns one spoken question into surface-specific subqueries, then concurrent workers run them over a Tor circuit pool under a single shared time budget. Extraction is done by a model reading the page, not by CSS selectors — leak sites redesign constantly and selectors rot within days.
An aggregator agent reconciles overlapping results into a canonical victim list with counts, dated quotes and evidence URLs, backed by an append-only SQLite victim store and a YAML registry of leak sites. Lucille receives that structure over MCP — nothing from a hidden service is ever rendered or executed on the Mac.
A safe-query gate runs before any circuit opens: research and monitoring questions pass; anything that looks like purchasing, credential lookup or targeting a private individual is refused out loud and logged. She also can't quietly clear a CAPTCHA — those pause and wait for a human.
| Layer | Built with | Owns |
|---|---|---|
| Engine | Python · Playwright · Tor SOCKS | Planner, workers, SiteAgent, extraction, ranking, aggregation — usable as a CLI or library |
| API | FastAPI · Redis streams | Auth, tiers, query SSE, workspaces, rate limits, audit trails; workers scale behind the stream |
| Web | React · Vite | Streaming query UI, citation copy/keyboard UX, pinned findings, source-registry transparency |
| Ops | Docker · Prometheus · Sentry | Structured JSON logging, metrics, deletion requests, deployment |
| MCP surface | stdio / HTTP | The three read-only tools Lucille is allowed to call — nothing that writes or buys |
Design note: this is the only place in the entire interface where color changes meaning. Everything else is phosphor white on near-black, so red reads instantly across the room — and it clears itself the moment the last worker returns, because a mode you can forget you left on is a bad mode.
Lucille already speaks MCP: servers are declared in her MCPServers plist, each auto-paired with a
toolset and an optional allowed_tools list. So adding Perplexity is a config entry, not a feature
build. The honest nuance is that Computer is an MCP client — it consumes servers rather than exposing
itself as one — so the two systems meet in the middle, from opposite directions.
Fig. 9 — two directions, two different pieces of work
Perplexity ships an official MCP server — hosted at https://mcp.perplexity.ai/mcp, or run
locally with npx -y @perplexity-ai/mcp-server and a PERPLEXITY_API_KEY — exposing
perplexity_search, perplexity_query, perplexity_reasoning and
perplexity_deep_research. Adding it means one entry in her plist plus an allowlist, and she
gains cited multi-source research alongside Anthropic's web_search.
Computer can't be called as a server, but it can call one: custom remote MCP connectors are configurable on Pro, Max and Enterprise — an HTTPS endpoint, Streamable HTTP or SSE, with API-key or OAuth auth. Publish Lucille's tool surface (and Abyssal's) behind Tailscale and a key, and a Computer task could send an email as her, or run a leak-site sweep, without any of it living in a browser.
Both directions follow the same principle the rest of the build follows: capability arrives as configuration with an explicit allowlist, so nothing destructive ever enters a voice assistant's reach by accident.
The face went green CRT → warm phosphor white → bigger, friendlier eyes → explicitly EVE-from-WALL·E, because the earlier robotic version read as unsettling. The final design carries emotion the way EVE does: through tilt and vertical scale, not through added features. There is no mouth — only a 24-bin waveform driven by real audio metering.
| State | Expression | Transform |
|---|---|---|
| .idle | neutral | Full capsules, 2.8 s float loop, random blinks at 15%/s |
| .listening | alert | Scaled 1.18× wide, 0.84× tall — round and surprised |
| .thinking | focused | Narrowed to 0.94, tilted 20° inward — determined |
| .speaking | happy | Crossfades to a thick upward smile crescent |
| .error | worried | Tilted 20° outward — the same trick, inverted |
| .darkSearch | wary | The only color change in the whole design: capsules crossfade to red and pulse while an Abyssal query is open |
Replies are split into sentences on ./!/? — but only when followed
by whitespace, so "3.5" and "Dr." don't split. Sentence n+1 is synthesized while n
plays. She starts talking as soon as the first clause is ready instead of waiting for the whole paragraph.
If the very first sentence fails at the neural endpoint, the whole reply falls back to
AVSpeechSynthesizer. A failure on a later sentence just skips that clip. Losing the TTS
server degrades her voice; it never silences her.
Append-only JSONL at
~/Library/Application Support/Lucille/conversation.jsonl, written on a serial dispatch
queue with a seek-to-end file handle. Timestamp, role, content. Nothing more.
recentContext(limit: 20) replays the last twenty turns into every request. Deliberately not
semantic — the next step is SQLite plus sqlite-vec for vector recall, swapped behind the same
interface.
Holding phrases. Barge-in fragments. Wake-word noise. Only real user turns and real answers land in the log, which is why a twenty-turn window stays useful instead of filling with "One sec."
UserDefaults, secrets moved out of Config.plist into the Keychain,
and telemetry lives in its own SQLite file with a CRT-styled dashboard.
Runtime state, in three places
The Mac keeps the brain, memory, and identity. The phone app mirrors her face live and acts as push-to-talk: it transcribes on the phone, sends only text, and speaks her reply back locally. Just small JSON messages cross the network, so it stays responsive on cellular.
47771 — face state down, commands up._lucille-face._tcp) on the same Wi-Fi; a Tailscale address anywhere else.
Installing a second tap on audio bus 0 throws an uncatchable Objective-C exception that kills the process.
A leaked tap from a torn-down session did exactly that. Fix: always removeTap before
installing, never gate teardown on a running flag, and bail-and-retry when the input format reports 0 Hz.
Interleaved research turns emit "let me search…", "let me fetch the primary source…" as real text blocks. Read aloud, it was unbearable. Fix: keep only the text after the last action block — reset the buffer on every non-text, non-thinking block.
Once a server-side web tool runs, every follow-up request must reference that code-execution container, or the API 400s on pending tool uses. Multi-step skills spanned that boundary. Fix: capture the container id from each response and thread it back.
At 4,096 output tokens, a turn doing adaptive thinking, multi-search research, and a full alert draft hit the ceiling before ever reaching the render tool. Raised to 16k with a 300 s timeout; the spoken reply stays 1–3 sentences because the prompt caps it, not the budget.
Swift files existed on disk but were never registered in project.pbxproj — so the app
compiled without them and behaved like the older build. Fix: register every source in all required
sections, then verify all 23 app sources are present.
The hand-rolled swiftc script compiles a hardcoded file list, so it silently omitted every
newer service. Xcode is the source of truth; the script is a convenience that must be maintained or
distrusted.
Retro-80s terminal concept, hybrid local/cloud routing, wake word plus ⌥Space, and the long
face iteration: green CRT → phosphor white → bigger eyes → EVE. Voice moved from cloud-natural to
local-first free.
One upgrade per hour for a day: long-term memory, response time, reminders and follow-ups, sound and smart-home services, SQLite telemetry with a dashboard, Keychain secret migration, prosody hints, and proactive suggestions.
The heuristic was extracted into a pure RouteDecision.swift with routing, prosody, and
store test suites, plus a dependency-free self-test harness — 17 assertions passing.
All 23 app Swift sources verified in the project file, Config.plist parsing validated, both
Python bridges byte-compiled, and the bundle repackaged at 78 files / 144 KB.
Claude with adaptive thinking and MCP support, the inbox watcher, the Mayer Brown client-alert skill behind two tools, the iPhone remote face over Bonjour and Tailscale, and barge-in everywhere.
MemoryStore for SQLite + sqlite-vec behind the existing append/recall pair.NSMetadataQuery, exposed as one more Swift tool.HMHomeManager, or Home Assistant's REST API as a bridge endpoint.build.sh from the project file, so the CLI path can never drift from Xcode again.