diff --git a/README.md b/README.md
index e9e586e..5507976 100644
--- a/README.md
+++ b/README.md
@@ -68,7 +68,7 @@ Cloud writing tools see everything you type. Halen doesn't. Three reasons that m
Halen picks the best available model on your Mac, automatically:
- **Apple Intelligence** if you have it (macOS 26+, supported Macs).
-- A small bundled model (Gemma 4 E4B + Qwen 2.5 for classification) if you don't.
+- A small local model — Gemma 4 E4B, plus Qwen 2.5 for classification — downloaded once on first use, if you don't.
- Your own [Ollama](https://ollama.com) daemon, if you've installed one.
Nothing to configure. The model picker in Settings is there if you want to.
diff --git a/Sources/Halen/Features/AskHalen/AskHalen.swift b/Sources/Halen/Features/AskHalen/AskHalen.swift
index 60b5e1c..5f81f8b 100644
--- a/Sources/Halen/Features/AskHalen/AskHalen.swift
+++ b/Sources/Halen/Features/AskHalen/AskHalen.swift
@@ -492,7 +492,7 @@ private struct AskHalenDetailView: View {
Text("Press ⌃H anywhere")
.font(.system(.callout, weight: .medium))
Text("A floating palette opens with your focused app, selection, and clipboard in context. Terminals consume ⌃H as backspace, so the hotkey won't fire inside Terminal or iTerm.")
- .font(.system(size: 11))
+ .font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
@@ -503,7 +503,7 @@ private struct AskHalenDetailView: View {
private var modelCard: some View {
card {
- cardHeader("Model")
+ cardLabel("Model")
Picker("", selection: $tierRaw) {
Text("Small").tag(ModelTier.small.rawValue)
Text("Medium").tag(ModelTier.medium.rawValue)
@@ -515,17 +515,18 @@ private struct AskHalenDetailView: View {
HStack(alignment: .firstTextBaseline) {
Text("Temperature")
- .font(.system(size: 11, weight: .medium))
+ .font(.caption)
+ .fontWeight(.medium)
.foregroundStyle(.secondary)
Spacer()
Text(String(format: "%.2f", temperature))
- .font(.system(size: 11, design: .monospaced))
+ .font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
}
.padding(.top, 4)
Slider(value: $temperature, in: 0.0...1.0, step: 0.05)
Text("Lower is more literal. Higher is more creative. 0.40 reads as a balanced default for question answering.")
- .font(.system(size: 10.5))
+ .font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
@@ -533,13 +534,13 @@ private struct AskHalenDetailView: View {
private var contextCard: some View {
card {
- cardHeader("Context")
+ cardLabel("Context")
Toggle(isOn: $includeParagraph) {
VStack(alignment: .leading, spacing: 1) {
Text("Include surrounding paragraph")
- .font(.system(size: 12))
+ .font(.callout)
Text("The paragraph your cursor is in. Off makes Ask Halen a pure Q&A surface.")
- .font(.system(size: 10.5))
+ .font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
@@ -552,9 +553,9 @@ private struct AskHalenDetailView: View {
Toggle(isOn: $includeClipboard) {
VStack(alignment: .leading, spacing: 1) {
Text("Include clipboard contents")
- .font(.system(size: 12))
+ .font(.callout)
Text("Recent clipboard text. Useful for \"summarise what I just copied,\" off if your clipboard has sensitive things you don't want a model to see.")
- .font(.system(size: 10.5))
+ .font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
@@ -567,11 +568,11 @@ private struct AskHalenDetailView: View {
private var privacyNote: some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "lock.fill")
- .font(.system(size: 11))
+ .font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 1)
Text("Whatever you turn on here is sent to the local model only. Nothing leaves your Mac.")
- .font(.system(size: 11))
+ .font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
@@ -594,12 +595,4 @@ private struct AskHalenDetailView: View {
)
)
}
-
- private func cardHeader(_ text: String) -> some View {
- Text(text)
- .font(.system(size: 10.5, weight: .semibold))
- .foregroundStyle(.secondary)
- .textCase(.uppercase)
- .tracking(0.6)
- }
}
diff --git a/Sources/Halen/Features/AskHalen/AskHalenPalette.swift b/Sources/Halen/Features/AskHalen/AskHalenPalette.swift
index e68f9c1..ab6cd22 100644
--- a/Sources/Halen/Features/AskHalen/AskHalenPalette.swift
+++ b/Sources/Halen/Features/AskHalen/AskHalenPalette.swift
@@ -108,6 +108,7 @@ struct AskHalenPalette: View {
.buttonStyle(.plain)
.disabled(state.question.isEmpty)
.keyboardShortcut(.return, modifiers: [])
+ .help("Send (⏎)")
.accessibilityLabel("Send")
.accessibilityHint("Sends your question to Halen.")
}
diff --git a/Sources/Halen/Plugins/Store/PluginStoreView.swift b/Sources/Halen/Plugins/Store/PluginStoreView.swift
index 2a288e9..dc6a8f0 100644
--- a/Sources/Halen/Plugins/Store/PluginStoreView.swift
+++ b/Sources/Halen/Plugins/Store/PluginStoreView.swift
@@ -318,7 +318,7 @@ private struct InstalledPluginRow: View {
private func tag(_ text: String) -> some View {
Text(text.uppercased())
- .font(.system(size: 8, weight: .bold))
+ .font(.caption2.weight(.bold))
.tracking(0.4)
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
diff --git a/docs/wiki/architecture.md b/docs/wiki/architecture.md
index 865fe7b..d42b765 100644
--- a/docs/wiki/architecture.md
+++ b/docs/wiki/architecture.md
@@ -4,8 +4,8 @@ One AX pipeline. One event bus. One inference router. Many plugins.
That's the whole shape. A single Swift Package menubar app
(`LSUIElement = true`) hosts the first-party plugins in-process and
-spawns third-party plugins as JSON-RPC subprocesses over stdio. ~13k
-lines of Swift, 168 unit tests under `Tests/HalenTests/`. Everything
+spawns third-party plugins as JSON-RPC subprocesses over stdio. ~20k
+lines of Swift, 169 unit tests under `Tests/HalenTests/`. Everything
flows through three seams: AX → events → inference. Plugins plug into
the middle one.
@@ -102,6 +102,13 @@ Ten in-process plugins ship as Swift classes wired into
`stop()` on toggle. Default-off plugins (Voice, Autocomplete, StyleGuide,
EmailReply, ToneProfiles) opt in via onboarding.
+These ten classes back the **six** entries you see in the marketplace —
+the v0.3 merges fold `TypoFixer` + `StyleGuide` into Word Replacements,
+`SentimentGuard` + `ClarityChecker` into Writing Coach, and `EmailReply`
+into Snippet Expander; `ToneProfiles` moved out of the marketplace into
+Settings. The class names (and the on-disk plugin ids) keep their
+pre-merge form so existing user data carries over without migration.
+
Out-of-process plugins — `BurnoutCopilot` and `MeetingPrep` ship in this
repo under `plugins/`; users can also drop their own into
`~/Library/Application Support/Halen/Plugins/` — are registered alongside
diff --git a/docs/wiki/getting-started.md b/docs/wiki/getting-started.md
index 05c01af..45a9666 100644
--- a/docs/wiki/getting-started.md
+++ b/docs/wiki/getting-started.md
@@ -149,11 +149,13 @@ Usage strings (`NSCalendarsUsageDescription`, `NSCalendarsFullAccessUsageDescrip
Burnout Copilot also writes a single 10-minute "🌿 Halen break" event when
you accept its suggestion — that requires the full-access scope.
-### 5. Notifications (Meeting Prep only)
+### 5. Notifications (Meeting Prep, Ask Halen, Email Reply)
`UNUserNotificationCenter.requestAuthorization(options: [.alert, .sound])`.
Meeting Prep posts one notification 1 second after a briefing lands on your
-clipboard. If you deny, the clipboard part still works.
+clipboard; Ask Halen and the `;reply` email drafter post one when a result
+falls back to the clipboard because the caret target couldn't be written. If
+you deny, the clipboard copy still happens silently.
### 6. Input Monitoring (Ask Halen + Snippet Expander rephrase)
diff --git a/docs/wiki/privacy.md b/docs/wiki/privacy.md
index 63d6e04..e03eb0e 100644
--- a/docs/wiki/privacy.md
+++ b/docs/wiki/privacy.md
@@ -1,15 +1,18 @@
# Privacy
-One constraint. Nothing leaves the machine.
+One constraint. Nothing you write leaves the machine.
Not the text near your cursor. Not your drafts. Not your voice.
Not a hashed fingerprint of any of it. Not an anonymous telemetry
ping. Nothing.
-The few egress points that do exist all hit localhost — the local
-inference backend you chose, or your local Ollama daemon if you
-opted in. This page documents every one of them, the per-plugin
-boundaries, and the on-disk files Halen writes.
+Inference runs on localhost — the backend you chose, or your local
+Ollama daemon if you opted in. Exactly two requests ever reach the
+public internet, and neither carries any of your content: a
+once-a-day software-update check (Sparkle), and a one-time model
+download if you opt into the bundled Gemma model. This page documents
+every one of them, the per-plugin boundaries, and the on-disk files
+Halen writes.
## What Halen sees
@@ -96,21 +99,32 @@ a remote process; only you can point it elsewhere. The request body contains:
With the default endpoint, that traffic does not leave the loopback interface
unless you have configured Ollama itself to bind to a non-localhost address.
-Two more outbound paths exist, both user-initiated and neither carrying any of
-your text:
-
-- **Bundled-model download.** If you choose to download the bundled Gemma 4
- model from Settings → Inference (instead of using Apple Intelligence or a
- `BUNDLE_MODEL=1` build), `ModelDownloader` fetches a single GGUF file from
- Hugging Face (`huggingface.co/unsloth/gemma-4-E4B-it-GGUF`). This is a
- one-time file transfer, not telemetry.
+A few more connections exist. **None of them carries any of your text, voice,
+or calendar data:**
+
+- **Software-update check.** Halen updates itself with
+ [Sparkle](https://sparkle-project.org), the standard open-source updater for
+ Mac apps. Once a day — and whenever you click *Check for Updates* in Settings
+ — it fetches the static appcast at `https://halen.dev/appcast.xml`. If a newer
+ build exists, the signed `.dmg` is downloaded from GitHub Releases and verified
+ against a pinned **EdDSA public key** before it installs. The request body is
+ empty: no account, no device id, no usage data — just the standard headers
+ (including your IP, as with any HTTPS request) needed to fetch a file. You can
+ turn automatic checks off from the "Automatically check for updates" toggle in
+ Sparkle's update dialog.
+- **Bundled-model download.** If you choose to download the Gemma 4 model from
+ Settings → Inference (instead of using Apple Intelligence or a `BUNDLE_MODEL=1`
+ build), `ModelDownloader` fetches a single GGUF file from Hugging Face
+ (`huggingface.co/unsloth/gemma-4-E4B-it-GGUF`). A one-time file transfer, not
+ telemetry.
- **Browser extension bridge.** If you enable the WebSocket bridge for the
- optional browser extension, Halen listens on `127.0.0.1:50765` (loopback
- only) so the extension can forward typing events from browser text fields.
+ optional browser extension, Halen listens on `127.0.0.1:50765` (loopback only)
+ so the extension can forward typing events from browser text fields. It only
+ *accepts* inbound connections; it never dials out.
-There is **no analytics SDK, no remote logging endpoint, no auto-updater, and
-no crash reporter** anywhere in the project. Nothing about your text, voice,
-or calendar is ever uploaded.
+Apart from the update check above, there is **no other outbound network code**
+in the project: no analytics SDK, no remote logging endpoint, no crash reporter.
+Nothing about your text, voice, or calendar is ever uploaded.
## Apple on-device speech recognition
@@ -163,7 +177,7 @@ Nothing is uploaded.
| Microphone | Voice Dictation | Capture audio for SFSpeechRecognizer |
| Speech Recognition | Voice Dictation | Convert audio to text **on-device** |
| Calendars (full access) | Burnout Copilot, Meeting Prep | Read events; Burnout writes the `🌿 Halen break` event |
-| Notifications | Meeting Prep | Post the "briefing ready" alert |
+| Notifications | Meeting Prep, Ask Halen | Briefing-ready alerts, and clipboard-fallback notices when a result can't be inserted at the caret |
| Input Monitoring | Ask Halen, Snippet Expander | Match the ⌃H and ⌃⌥R hotkeys system-wide — only those hotkeys, no other keystrokes |
You can deny any of these and the host continues to run. The dependent
diff --git a/index.html b/index.html
index 23cc614..43dad3f 100644
--- a/index.html
+++ b/index.html
@@ -1348,7 +1348,7 @@
No accounts.
-
Halen sits in your menu bar, near your cursor. The models never leave your Mac.
+
Halen lives in your menu bar, near your cursor. The models never leave your Mac.
Your words stay yours.
@@ -1361,7 +1361,7 @@
-
Plugins for the cursor.
+
Tools for the cursor.
Pick one. Watch what it does.
@@ -1375,7 +1375,7 @@
Plugins for the cursor.
@@ -1465,7 +1465,7 @@
Plugins for the cursor.
HALEN · matched
- expanding ;polite via gemma4:e4b
+ expanding ;polite via Gemma 4 E4B
⏎
@@ -1473,7 +1473,7 @@
Plugins for the cursor.
!
This reads as hostile
- gemma4:e4b · 100% local · 118 ms
+ Gemma 4 E4B · 100% local · 118 ms
Send anyway
@@ -1534,7 +1534,7 @@
Plugins for the cursor.
Gemma 4 E2B
Gemma 4 E4B
-
Gemma 4 27B
+
Gemma 4 26B
@@ -1612,7 +1612,7 @@
At your cursor. All on-device.
macOS 14+
- Apple Silicon only
+ Apple Silicon nativeFree · MIT
@@ -2125,7 +2125,7 @@
Trusted by people who write all day on a Mac.
prefix: '',
rewritePrior: 'Hey Alex, just lock the scope already. We’ve redone this three times.',
expanded: 'Hey Alex, could we lock the scope this week? I want to make sure we land it cleanly.',
- pop: { l: 'HALEN · matched', v: 'softening prior paragraph via gemma4:e4b' }, delay: 950,
+ pop: { l: 'HALEN · matched', v: 'softening prior paragraph via Gemma 4 E4B' }, delay: 950,
},
// Writing Coach (tone tab) — Qwen 2.5 0.5B classifier on each sentence
// end. Hostile label triggers the guard card with Send-anyway and
@@ -2339,7 +2339,7 @@
What Halen sees, what stays on your Mac, and the exact set of outbound connections (all loopback).
+
What Halen sees, what stays on your Mac, and the only connection that ever leaves it.
+
Last updated 9 June 2026 · applies to Halen v0.3.0
- Halen is built around one constraint: nothing leaves the machine. This page documents what data the app sees, where it’s processed, and the few egress points that do exist (all to localhost).
+ Halen is built around one constraint: nothing you write leaves the machine. Every model runs on-device. The single outbound connection is the signed software-update check documented below — never your text.
What Halen sees
@@ -217,14 +222,23 @@
What stays local
Network traffic
-
Halen makes only loopback connections. Three possible paths, all to 127.0.0.1:
+
No inference ever leaves your Mac. The text-processing paths are loopback only — three of them, all to 127.0.0.1:
Apple Foundation Models: runs in-process via Apple’s on-device API (macOS 26+). No socket at all.
Bundled llama.cpp + Gemma 4 E4B: also in-process. No socket.
Ollama (optional): HTTP POST to http://localhost:11434/api/chat. The base URL is set in code; there is no setting that would let a process change it to a remote host. Ollama itself binds to 127.0.0.1 by default.
That traffic does not leave the loopback interface unless you have separately configured Ollama to bind to a non-localhost address.
-
There is no other outbound network code in the project. No analytics SDK, no remote logging endpoint, no auto-updater, no crash reporter. The optional WebSocket bridge on 127.0.0.1:50765 only accepts inbound connections from the local browser extension; it never dials out.
+
Aside from the software-update check described next, there is no other outbound network code in the project. No analytics SDK, no remote logging endpoint, no crash reporter. The optional WebSocket bridge on 127.0.0.1:50765 only accepts inbound connections from the local browser extension; it never dials out.
+
+
Software updates
+
Halen keeps itself up to date with Sparkle, the standard open-source updater for Mac apps. This is the one connection that does leave your Mac, and it carries none of your content:
+
+
The check: a plain HTTPS GET of https://halen.dev/appcast.xml — a static, public file listing the latest version. It runs once a day and whenever you click Check for Updates in Settings.
+
The download: if a newer version exists, the signed .dmg is fetched from GitHub Releases (github.com). Every update is verified against a pinned EdDSA public key before it’s installed, so a tampered or man-in-the-middle payload is rejected.
+
What’s sent: nothing beyond an ordinary web request. No account, no device identifier, no usage data — just the standard headers (including your IP, as with any HTTPS request) needed to fetch a file. The request body is empty.
+
+
Automatic checks are on by default. You can turn them off from the “Automatically check for updates” toggle in Sparkle’s update dialog, after which Halen makes zero non-loopback connections.