Lucille
Build deck  ·  v1  ·  Mac mini

LUCILLE

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

01 — The premise

Not a chat window. A presence on the desk.

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.

21Swift files
3,559lines of Swift
2brains, one router
12tools in her hands
02 — What she's built on

Apple frameworks on the outside, two model runtimes on the inside.

Shell SwiftUI

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.

Ears Speech

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.

Voice Kokoro / AVF

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.

Fast brain Ollama

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.

Deep brain Claude

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.

Bridges Python

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.

03 — The signal chain

From air pressure to an answer, and back out loud.

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.

Mic · ⌥Space wake word "lucille" SpeechService on-device STT AssistantViewModel state machine LucilleBrain.route auto · local · cloud Ollama · qwen2.5 :11434 · offline Claude · tool loop thinking · web · MCP OR Reply text 1–3 spoken sentences SpeechSynthService Kokoro :8123 → AVF Speaker + mouth 24-bin amplitude Google bridge :8770 · Gmail · Cal MemoryStore conversation.jsonl FaceStateServer NDJSON over :47771 iPhone face Bonjour / Tailscale LOCAL / FREE PATH CLOUD / TOOL PATH STATE FAN-OUT ON THE MAC

Fig. 1 — every box is a file; every arrow is a closure or an HTTP call

04 — The decision tree

Four trees run her: route, turn loop, inbox, dark web.

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.

Utterance transcribed respond(to:) on the main actor routingMode == .auto ? menu-bar override, persisted NO Honor the forced route ROUTE: LOCAL or ROUTE: CLOUD YES Exact trivial phrase? hi · thanks · ok · yep · stop bye · sounds good · go ahead YES NO ≤ 2 words and not a question opener? what · why · how · can · should… YES NO CLOUD — Claude tools, thinking, web, MCP LOCAL — Ollama qwen2.5 · answers in well under a second · costs nothing, never leaves the Mac · no tools, no live web access · also used for holding phrases and inbox triage Everything substantive default is cloud; local is the exception DESIGN NOTE — INVERTED FROM THE FIRST DRAFT: LOCAL WAS THE DEFAULT, UNTIL TOOL-USE RELIABILITY MADE CLOUD THE SAFER FALLBACK.

Fig. 2 — chooseRoute(for:mode:), drawn as it executes

LucilleBrain.swift · routing logic
// 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
}
05 — The trick that makes her feel fast

She answers before she has the answer.

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.

T = 0 ~0.4 s 6–20 s CLOUD TASK Claude · adaptive thinking · web_fetch · tool calls LOCAL ACK qwen2.5 · max_tokens 24 SPOKEN "Let me check that." the real answer BARGE-IN: ⌥SPACE, THE MENU-BAR STOP BUTTON, OR AN ECHO-CANCELLED SPOKEN "STOP" CANCELS BOTH LANES.

Fig. 6 — perceived latency ≈ 0.4 s, actual latency up to 20 s

06 — What she can actually do

Seventeen tools across four execution surfaces.

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.

07 — The client-alert skill

Her most useful tool is a five-call chain she is forbidden to shortcut.

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

Spoken: “draft an alert on the consent order” HOTKEY OR WAKE WORD · CLOUD ROUTE, ALWAYS Inbox: an allowlisted colleague asks EMAILWATCHER · TRIAGED LOCALLY · DRAINS WHEN IDLE 1 · client_alert_style_guide GET :8770/client_alert/guide — style, cross-links, alert schema THE SYSTEM PROMPT MAKES THIS HER FIRST ACTION, NO EXCEPTIONS — STYLE IS AN INPUT, NOT A MEMORY 2 · web_search + web_fetch read the underlying order or rule, then search mayerbrown.com for prior Mayer Brown coverage worth cross-linking SERVER-SIDE CODE EXECUTION — THE CONTAINER ID MUST BE THREADED BACK INTO EVERY LATER TURN OR THE API 400s 3 · build the alert object kicker · date · headline · authors · body as hyperlink runs · contacts · outputName — exactly the schema step 1 handed back IN-TEXT HYPERLINKS, NOT FOOTNOTES — TO BOTH THE SOURCE AND THE PRIOR MB PUBLICATIONS. THAT IS HIS STYLE. 4 · create_client_alert POST :8770/client_alert/create · from = “lucille” by default · cc falls back to AlertCC in Swift when the model omits it OVERSIGHT IS ENFORCED IN CODE, NOT IN THE PROMPT — TANNER IS CC'd EVEN IF THE MODEL FORGETS TO ASK FOR IT 5 · .docx rendered, emailed, then she speaks Node docx writes the file — Word never opens on the Mac mini SHE MAY NOT SAY “DONE” UNTIL THE TOOL RESULT CONFIRMS THE SEND

Fig. 7 — five calls, one turn, no stopping to ask permission

Style as a resource not a prompt

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.

Deterministic rendering .docx

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.

A draft, explicitly review-ready

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.

What it took to make this work at all

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.

08 — Dark web mode

When she reaches into Abyssal, her eyes turn red.

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.

faceState = .idle Phosphor white. Everything else she does — local model, cloud model, inbox, calendar.
faceState = .darkSearch Red, and pulsing slowly. A Tor circuit is open somewhere on her behalf, right now.
face · .darkSearch
  1. 00.0s“lucille, check the leak sites for new Clop victims”
  2. 00.4ssafe-query gate → research intent, allowed
  3. 00.5sfaceState = .darkSearch — broadcast to Mac + phone
  4. 00.9splanner → 4 subqueries across 6 registry sites
  5. 02.1stor circuit pool: 6 workers, 90 s shared budget
  6. 08.7sLLM extraction → 19 victim rows, 3 dated posts
  7. 11.2saggregator → canonical list, evidence URLs
  8. 11.6sshe speaks the count; detail goes to a file
  9. 11.9sfaceState = .idle — eyes back to phosphor white
One natural-language question no query syntax, no site list, no operators Safe-query gate · warm cache refuse or serve from cache before any circuit opens Planner · LLM decomposition one question → several surface-specific subqueries, each shaped for how that surface actually indexes Ransomware leak sites TOR · YAML REGISTRY Forums · paste sites AUTH + CAPTCHA HINTS Telegram channels CLAIM + BROKER TRAFFIC I2P eepsites SECONDARY MIRRORS Clearnet reporting CORROBORATION LAYER SiteAgent · Playwright over Tor circuit pool under one shared time budget · Tor preflight fails loudly · mirror health · screenshots + OCR · CAPTCHA → pause LLM extraction, not selectors strip and truncate the page, then ask a model for structured findings — survives the redesigns that break CSS scrapers Rank · dedupe SimHash near-duplicate collapse, per-source confidence weights AggregatorAgent canonical entity list, counts, evidence URLs, source breakdown Evidence-dump synthesis victim table, then verbatim dated quotes grouped by source — never a vague paragraph Append-only SQLite victim store · monitor daemon · watchlists · STIX / CSV / JSON export · SSE live-fetch events · audit log EVERY ANSWER IS REPRODUCIBLE LATER — WHICH IS THE WHOLE POINT FOR EVIDENCE THAT DISAPPEARS WHEN A SITE GOES DOWN

Fig. 8 — the Abyssal query lifecycle: fan out wide, extract with a model, reconcile once

Ask, don't crawl planner

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.

Findings, not pages aggregator

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.

The gate comes first safety

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.

The parts that are actually hard

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.

09 — Perplexity over MCP

Yes — but the arrow points both ways.

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.

Lucille · Mac mini MCP client (plist-configured) + MCP server surface for her own tools and Abyssal Perplexity Sonar MCP server · hosted Computer · MCP client with custom remote connectors A · she calls perplexity_search / perplexity_deep_research B · Computer calls her tools as a remote connector A IS A PLIST ENTRY TODAY. B REQUIRES EXPOSING HER TOOL SURFACE AT AN HTTPS URL — ROADMAP, NOT SHIPPED. EITHER WAY THE SECRET STAYS IN THE KEYCHAIN; NEITHER SIDE SEES THE OTHER'S CREDENTIALS.

Fig. 9 — two directions, two different pieces of work

A · Lucille → Perplexity works today

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.

B · Computer → Lucille roadmap

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.

10 — Face and voice

Six states, six expressions, one glowing visor.

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
.idleneutralFull capsules, 2.8 s float loop, random blinks at 15%/s
.listeningalertScaled 1.18× wide, 0.84× tall — round and surprised
.thinkingfocusedNarrowed to 0.94, tilted 20° inward — determined
.speakinghappyCrossfades to a thick upward smile crescent
.errorworriedTilted 20° outward — the same trick, inverted
.darkSearchwaryThe only color change in the whole design: capsules crossfade to red and pulse while an Abyssal query is open

Streaming synthesis time to first word

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.

Graceful voice fallback never mute

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.

11 — Memory

A JSONL log, and the discipline to keep it clean.

Storage

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.

Recall

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.

What is not stored

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

Settings live in 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
12 — The remote face

The iPhone is a handset, not a second brain.

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.

13 — What broke, and what it taught

Most of the good code here is scar tissue.

The wake-word crash SIGABRT

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.

She narrated her own thinking voice UX

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.

Client alerts died mid-draft container_id

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.

Starved on tokens max_tokens

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.

Nine invisible files pbxproj

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 build script lied build.sh

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.

14 — How it was actually built

A weekend prototype, then a day of hourly passes.

Jun 12–13

Prototype and art direction

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.

Jun 15

The 24-pass improvement cycle

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.

Jun 15 · H23

Routing became testable

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.

Jun 15 · H24

Made genuinely buildable

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.

Since

The agentic era

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.

15 — Where she goes next

The interfaces are already in place; only the guts change.

Every capability she has arrived as a tool, a bridge endpoint, or a prompt rule — never as a rewrite of the thing that decides. The one architectural rule that held