From 70ce19d0e27c33ee6ad152bc5b113c4959c369ae Mon Sep 17 00:00:00 2001
From: iret77 <63622643+iret77@users.noreply.github.com>
Date: Thu, 30 Jul 2026 19:35:15 +0200
Subject: [PATCH] =?UTF-8?q?feat:=20notify=20tool=20=E2=80=94=20native=20ma?=
=?UTF-8?q?cOS=20notification=20(#17)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fire-and-forget async-completion signal for the agent ("tests green",
"deploy done", "merge conflicts, need you"). Unlike confirm/ask/form/
gallery this does not block on a user response: the companion hands the
notification straight to macOS's UNUserNotificationCenter via
tauri-plugin-notification and the tool call returns {ok: true}
immediately — no dialog window, no registry entry, no progress
keepalive.
- New POST /notify endpoint on the companion (http.rs), with pure/
unit-tested helpers for title validation and body+subtitle
composition.
- Registered as an MCP tool in both bridges: the native Rust server
(mcp.rs) and the Python remote bridge (aiui_mcp/server.py), same
{title, body, subtitle?, sound?} shape and error handling in both.
- docs/skill.md (canonical + the python-bundled copy) documents notify
alongside confirm/ask/form/gallery, with anti-patterns and an updated
tool-choice table.
- CHANGELOG + version bump (0.8.3 -> 0.9.0, all three manifests) per
this repo's per-feature versioning convention.
---
CHANGELOG.md | 18 ++
README.md | 1 +
companion/src-tauri/Cargo.lock | 59 ++++++
companion/src-tauri/Cargo.toml | 5 +
companion/src-tauri/capabilities/default.json | 3 +-
companion/src-tauri/src/http.rs | 177 ++++++++++++++++++
companion/src-tauri/src/lib.rs | 4 +
companion/src-tauri/src/mcp.rs | 67 +++++++
docs/skill.md | 54 +++++-
python/src/aiui_mcp/server.py | 70 +++++++
python/src/aiui_mcp/skill.md | 34 +++-
python/tests/test_notify.py | 166 ++++++++++++++++
12 files changed, 651 insertions(+), 7 deletions(-)
create mode 100644 python/tests/test_notify.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9196b39..cf87217 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -70,6 +70,24 @@ All notable changes to this project are documented here.
host. Both bridges (Rust companion and the `aiui-mcp` PyPI package for
remote/headless use) implement the same routing so behavior doesn't
drift between the two.
+- **`notify` tool — native macOS notification (#17).** A fire-and-forget
+ async-completion signal for the agent ("tests green", "deploy done",
+ "merge conflicts, need you") that, unlike `confirm`/`ask`/`form`/
+ `gallery`, does not block on a user response: the companion hands the
+ notification to macOS's `UNUserNotificationCenter` (via
+ `tauri-plugin-notification`) and the tool call returns `{ok: true}`
+ immediately — no dialog window, no registry entry, no progress
+ keepalive. Takes `title`/`body` (required) plus optional `subtitle` and
+ `sound`. New `POST /notify` companion endpoint, registered in both the
+ native Rust MCP server (`mcp.rs`) and the Python remote bridge
+ (`aiui_mcp/server.py`) with matching behavior. First send triggers the
+ one-time macOS notification-permission prompt, same as any native app.
+
+### Changed
+
+- **`docs/skill.md`** documents `notify` alongside `confirm`/`ask`/`form`/
+ `gallery`, with the "when NOT to use chat" guidance extended to cover
+ async-completion signals.
## [0.8.3] — 2026-07-29
diff --git a/README.md b/README.md
index 5498d39..6be09c0 100644
--- a/README.md
+++ b/README.md
@@ -107,6 +107,7 @@ back in chat — without it, the agent might forget aiui exists.
| Destructive actions with a vague "please confirm" | Red-styled yes/no, unambiguous |
| Ad-hoc local web UIs for one-off tasks | No longer needed |
| Remote hosts where the agent has no way to ask you | Dialogs tunnel back to your Mac automatically |
+| A long task finishes while you've tabbed away | A native macOS notification — no dialog, nothing to click |
diff --git a/companion/src-tauri/Cargo.lock b/companion/src-tauri/Cargo.lock
index 852ae46..425d2f7 100644
--- a/companion/src-tauri/Cargo.lock
+++ b/companion/src-tauri/Cargo.lock
@@ -52,6 +52,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
+ "tauri-plugin-notification",
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
@@ -2368,6 +2369,20 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+[[package]]
+name = "mac-notification-sys"
+version = "0.6.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
+dependencies = [
+ "cc",
+ "log",
+ "objc2",
+ "objc2-foundation",
+ "time",
+ "uuid",
+]
+
[[package]]
name = "markup5ever"
version = "0.14.1"
@@ -2537,6 +2552,20 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
+[[package]]
+name = "notify-rust"
+version = "4.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
+dependencies = [
+ "futures-lite",
+ "log",
+ "mac-notification-sys",
+ "serde",
+ "tauri-winrt-notification",
+ "zbus",
+]
+
[[package]]
name = "ntapi"
version = "0.4.3"
@@ -4709,6 +4738,25 @@ dependencies = [
"time",
]
+[[package]]
+name = "tauri-plugin-notification"
+version = "2.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
+dependencies = [
+ "log",
+ "notify-rust",
+ "rand 0.9.4",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.18",
+ "time",
+ "url",
+]
+
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
@@ -4867,6 +4915,17 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
+[[package]]
+name = "tauri-winrt-notification"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
+dependencies = [
+ "thiserror 2.0.18",
+ "windows 0.61.3",
+ "windows-version",
+]
+
[[package]]
name = "tempfile"
version = "3.27.0"
diff --git a/companion/src-tauri/Cargo.toml b/companion/src-tauri/Cargo.toml
index 2cb25f3..7dea1ae 100644
--- a/companion/src-tauri/Cargo.toml
+++ b/companion/src-tauri/Cargo.toml
@@ -33,6 +33,11 @@ tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-dialog = "2"
tauri-plugin-process = "2"
+# Native OS notification for the `notify` MCP tool (#17) — fire-and-forget
+# agent → user signal ("tests green", "deploy done") that doesn't block on a
+# dialog response. On macOS this drives UNUserNotificationCenter (one-time
+# permission prompt on first send, same as any other native app).
+tauri-plugin-notification = "2"
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
diff --git a/companion/src-tauri/capabilities/default.json b/companion/src-tauri/capabilities/default.json
index 7524905..e1aad8e 100644
--- a/companion/src-tauri/capabilities/default.json
+++ b/companion/src-tauri/capabilities/default.json
@@ -7,6 +7,7 @@
"core:default",
"updater:default",
"dialog:default",
- "process:default"
+ "process:default",
+ "notification:default"
]
}
diff --git a/companion/src-tauri/src/http.rs b/companion/src-tauri/src/http.rs
index 2ab4a74..cbcc3f8 100644
--- a/companion/src-tauri/src/http.rs
+++ b/companion/src-tauri/src/http.rs
@@ -17,6 +17,7 @@ use std::time::{Duration, Instant};
use crate::logging::trace;
use tauri::{AppHandle, Emitter, Manager};
use tauri_plugin_dialog::DialogExt;
+use tauri_plugin_notification::NotificationExt;
use tauri_plugin_updater::UpdaterExt;
/// How long `/health` waits for a `ui:ping` round-trip from the frontend
@@ -162,6 +163,34 @@ struct UpdateResponse {
note: Option,
}
+/// Body for `POST /notify` — backs the `notify` MCP tool (#17). Unlike
+/// confirm/ask/form/gallery this is fire-and-forget: no dialog window, no
+/// registry entry, no wait on a user response. `title` and `body` are
+/// required (empty `title` is rejected below, mirroring the `confirm`
+/// tool's requirement); `subtitle` and `sound` are optional and silently
+/// ignored on platforms/notification backends that don't support them.
+#[derive(Deserialize)]
+struct NotifyRequest {
+ #[serde(default)]
+ title: String,
+ #[serde(default)]
+ body: String,
+ #[serde(default)]
+ subtitle: Option,
+ /// Notification sound name. macOS: a system sound name (e.g.
+ /// `"default"`) or omit for silent. Passed through as-is; an invalid
+ /// name is swallowed by the OS rather than erroring the call.
+ #[serde(default)]
+ sound: Option,
+}
+
+#[derive(Serialize)]
+struct NotifyResponse {
+ ok: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option,
+}
+
pub async fn serve(
cfg: Arc,
dialog: Arc,
@@ -197,6 +226,7 @@ pub async fn serve(
.route("/health", get(health))
.route("/render", post(render))
.route("/render/:id", get(render_poll))
+ .route("/notify", post(notify))
.route("/version", get(version))
.route("/update", post(update))
.route("/ping", get(ping))
@@ -1248,6 +1278,93 @@ async fn render(
.into_response()
}
+/// A `notify` title must carry actual text — an empty/whitespace-only title
+/// would show a blank banner headline. Pulled out as a pure predicate (like
+/// `validate_spec` above) so the rule is unit-testable without a Tauri app.
+fn notify_title_is_valid(title: &str) -> bool {
+ !title.trim().is_empty()
+}
+
+/// Combine `body` and an optional `subtitle` into the single text handed to
+/// `NotificationBuilder::body`. `tauri-plugin-notification`'s builder has no
+/// dedicated `subtitle()` — that concept only exists on macOS's notification
+/// banner — so a caller-supplied subtitle is folded into the body instead of
+/// silently disappearing cross-platform. Returns `None` when there is
+/// nothing to show (both blank), so the caller can skip `.body()` entirely
+/// rather than rendering an empty line.
+fn compose_notify_body(body: &str, subtitle: Option<&str>) -> Option {
+ let subtitle = subtitle.map(str::trim).filter(|s| !s.is_empty());
+ let body = Some(body.trim()).filter(|s| !s.is_empty());
+ match (subtitle, body) {
+ (Some(s), Some(b)) => Some(format!("{s}\n{b}")),
+ (Some(s), None) => Some(s.to_string()),
+ (None, Some(b)) => Some(b.to_string()),
+ (None, None) => None,
+ }
+}
+
+/// `POST /notify` — fire a native OS notification and return immediately
+/// (#17). Deliberately NOT modeled on `/render`: there is no dialog window,
+/// no registry entry, no user response to wait on — the whole point of
+/// `notify` (vs. `confirm`/`ask`/`form`) is that the agent doesn't block on
+/// it. `tauri-plugin-notification` hands the request to the OS notification
+/// center (`UNUserNotificationCenter` on macOS) and returns as soon as that
+/// hand-off succeeds; it does not wait for the user to see or dismiss it.
+async fn notify(
+ State(state): State,
+ headers: HeaderMap,
+ Json(req): Json,
+) -> impl IntoResponse {
+ if !auth_ok(&headers, &state.cfg.token) {
+ return (
+ StatusCode::UNAUTHORIZED,
+ Json(serde_json::json!({"error": "unauthorized"})),
+ )
+ .into_response();
+ }
+ if !notify_title_is_valid(&req.title) {
+ return (
+ StatusCode::UNPROCESSABLE_ENTITY,
+ Json(serde_json::json!({
+ "error": "invalid_request",
+ "detail": "title must not be empty",
+ })),
+ )
+ .into_response();
+ }
+
+ let mut builder = state.app.notification().builder().title(&req.title);
+ if let Some(body) = compose_notify_body(&req.body, req.subtitle.as_deref()) {
+ builder = builder.body(body);
+ }
+ if let Some(sound) = req.sound.as_deref().filter(|s| !s.is_empty()) {
+ builder = builder.sound(sound);
+ }
+
+ match builder.show() {
+ Ok(()) => {
+ trace("notify: shown");
+ (StatusCode::OK, Json(NotifyResponse { ok: true, error: None })).into_response()
+ }
+ Err(e) => {
+ // Not fatal to the caller — surface `{ok: false, error}` rather
+ // than a 5xx, mirroring the tolerant style of the other
+ // best-effort endpoints (e.g. media upload). A missing/denied
+ // OS notification permission is the expected failure mode here,
+ // not a bug.
+ trace(&format!("notify: show failed: {e}"));
+ (
+ StatusCode::OK,
+ Json(NotifyResponse {
+ ok: false,
+ error: Some(e.to_string()),
+ }),
+ )
+ .into_response()
+ }
+ }
+}
+
#[cfg(test)]
mod validate_tests {
use super::validate_spec;
@@ -1457,6 +1574,66 @@ mod render_guard_tests {
}
}
+#[cfg(test)]
+mod notify_tests {
+ use super::{compose_notify_body, notify_title_is_valid};
+
+ #[test]
+ fn title_must_be_non_empty() {
+ assert!(!notify_title_is_valid(""));
+ assert!(!notify_title_is_valid(" "));
+ assert!(!notify_title_is_valid("\t\n"));
+ }
+
+ #[test]
+ fn title_with_content_is_valid() {
+ assert!(notify_title_is_valid("Deploy finished"));
+ // Leading/trailing whitespace around real content is fine — only
+ // whitespace-*only* titles are rejected.
+ assert!(notify_title_is_valid(" Deploy finished "));
+ }
+
+ #[test]
+ fn compose_body_prefers_both_when_present() {
+ assert_eq!(
+ compose_notify_body("All tests passed.", Some("CI")),
+ Some("CI\nAll tests passed.".to_string())
+ );
+ }
+
+ #[test]
+ fn compose_body_falls_back_to_subtitle_only() {
+ assert_eq!(
+ compose_notify_body("", Some("CI finished")),
+ Some("CI finished".to_string())
+ );
+ }
+
+ #[test]
+ fn compose_body_falls_back_to_body_only() {
+ assert_eq!(
+ compose_notify_body("All tests passed.", None),
+ Some("All tests passed.".to_string())
+ );
+ }
+
+ #[test]
+ fn compose_body_none_when_both_blank() {
+ assert_eq!(compose_notify_body("", None), None);
+ assert_eq!(compose_notify_body(" ", Some(" ")), None);
+ }
+
+ #[test]
+ fn compose_body_ignores_blank_subtitle() {
+ // A whitespace-only subtitle must not produce a spurious blank line
+ // above the real body text.
+ assert_eq!(
+ compose_notify_body("All tests passed.", Some(" ")),
+ Some("All tests passed.".to_string())
+ );
+ }
+}
+
#[cfg(test)]
mod async_render_tests {
use super::{drain_async_slot, AsyncSlot, SlotLook};
diff --git a/companion/src-tauri/src/lib.rs b/companion/src-tauri/src/lib.rs
index 7859777..e919746 100644
--- a/companion/src-tauri/src/lib.rs
+++ b/companion/src-tauri/src/lib.rs
@@ -1409,6 +1409,10 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
+ // Backs the `notify` MCP tool (#17) — native OS notification shown
+ // directly from Rust (http.rs `/notify`), no capability grant needed
+ // since it's never invoked from the WebView side.
+ .plugin(tauri_plugin_notification::init())
.manage(cfg.clone())
.manage(dialog_state.clone())
.manage(ui_acks.clone())
diff --git a/companion/src-tauri/src/mcp.rs b/companion/src-tauri/src/mcp.rs
index 13879ff..3120eb4 100644
--- a/companion/src-tauri/src/mcp.rs
+++ b/companion/src-tauri/src/mcp.rs
@@ -47,6 +47,9 @@ instead of asking via chat. Default behaviour for this session:
- User wants to hand you a file from their Mac (`/aiui:upload`, \
\"take this file\", \"upload …\") → call `upload` with the target \
directory on your host; don't ask them to `scp` it.
+- Async-completion signal the user doesn't need to answer (tests green, \
+ deploy done, merge conflict) → call `notify` — it returns immediately, \
+ no dialog, no reply expected.
- Pure information the user only reads → keep it in chat.
Type `/aiui:teach` for the full widget catalog when composing a \
@@ -466,6 +469,20 @@ fn tools_list() -> Value {
}
}
},
+ {
+ "name": "notify",
+ "description": "Fire a native macOS notification and return immediately — use this for an async-completion signal to a user who isn't watching this session (\"tests green\", \"deploy finished\", \"merge conflicts, need you\"). Unlike confirm/ask/form/gallery, this tool does NOT wait for the user: it hands the notification to the OS and returns {ok: true} right away, with no dialog, no window, no response to parse. Use it instead of a chat message when the point is exactly that the user doesn't have to be looking at this session to notice. For anything that needs an answer (yes/no, a choice, input), use confirm/ask/form — notify has no way to carry a reply back. `title` is required and short (≤ ~40 chars, notification banners truncate); `body` carries the detail. `subtitle` is optional extra context (folded into the body on platforms without a distinct subtitle slot). `sound` is an optional OS sound name (e.g. \"default\"); omit for a silent notification.",
+ "inputSchema": {
+ "type": "object",
+ "required": ["title", "body"],
+ "properties": {
+ "title": { "type": "string", "description": "Short headline, ≤ ~40 chars — notification banners truncate longer text." },
+ "body": { "type": "string", "description": "The detail — what finished, what needs attention." },
+ "subtitle": { "type": "string", "description": "Optional extra context line." },
+ "sound": { "type": "string", "description": "Optional OS notification sound name (e.g. \"default\"). Omit for silent." }
+ }
+ }
+ },
{
"name": "aiui_health",
"description": "Reachability check against the local aiui companion. Returns version + ready flag if the companion is running and responding.",
@@ -759,6 +776,20 @@ async fn tools_call(
format_dialog_result,
),
+ "notify" => post_json(
+ http,
+ cfg,
+ "/notify",
+ json!({
+ "title": args.get("title"),
+ "body": args.get("body"),
+ "subtitle": args.get("subtitle"),
+ "sound": args.get("sound")
+ }),
+ )
+ .await
+ .map(value_to_tool_text),
+
"aiui_health" => get_json(http, cfg, "/health").await.map(value_to_tool_text),
"version" => get_json(http, cfg, "/version").await.map(value_to_tool_text),
"update" => post_empty(http, cfg, "/update")
@@ -1299,6 +1330,42 @@ async fn post_empty(
.map_err(|e| format!("parse {path}: {e}"))
}
+/// POST with a JSON body, returning the parsed response. Backs `notify`
+/// (#17) — unlike `render_dialog`, there is no async-poll dance here: the
+/// companion's `/notify` handler is itself fire-and-forget and answers
+/// synchronously the moment the OS accepts the notification. A non-2xx
+/// status surfaces the response body (if any) so a 422 `invalid_request`
+/// detail from the companion reaches the agent instead of a bare status
+/// code.
+async fn post_json(
+ http: &reqwest::Client,
+ cfg: &AppConfig,
+ path: &str,
+ body: Value,
+) -> Result {
+ let token = load_token(cfg)?;
+ let url = format!("{}{}", base_url(cfg), path);
+ let resp = http
+ .post(&url)
+ .bearer_auth(&token)
+ .json(&body)
+ .send()
+ .await
+ .map_err(|e| format!("POST {path}: {e}"))?;
+ let status = resp.status();
+ if !status.is_success() {
+ let detail = resp.text().await.unwrap_or_default();
+ return Err(if detail.is_empty() {
+ format!("{path} http {status}")
+ } else {
+ format!("{path} http {status}: {detail}")
+ });
+ }
+ resp.json::()
+ .await
+ .map_err(|e| format!("parse {path}: {e}"))
+}
+
// MCP tool-result shape: { content: [...], structuredContent?: ..., isError? }
fn value_to_tool_text(v: Value) -> Value {
json!({
diff --git a/docs/skill.md b/docs/skill.md
index 74a0103..700935c 100644
--- a/docs/skill.md
+++ b/docs/skill.md
@@ -1,17 +1,20 @@
---
name: aiui
-description: Render native desktop dialogs on the user's machine via aiui's MCP server — `confirm` before destructive actions (delete, drop, force-push, deploy), `ask` for pick-one-of-N where context per option matters, `form` for multi-input requests, secrets, dates, sliders, sortable lists, or image confirmation, `compare` for A/B(/C) side-by-side picks, `gallery` for batch image/video review.
+description: Render native desktop dialogs on the user's machine via aiui's MCP server — `confirm` before destructive actions (delete, drop, force-push, deploy), `ask` for pick-one-of-N where context per option matters, `form` for multi-input requests, secrets, dates, sliders, sortable lists, or image confirmation, `compare` for A/B(/C) side-by-side picks, `gallery` for batch image/video review, `notify` for a fire-and-forget completion signal that doesn't block on a reply.
---
# aiui — Dialog design for Claude agents
-aiui exposes five MCP tools that render native dialogs on the user's machine:
+aiui exposes MCP tools that render native dialogs on the user's machine,
+plus one that doesn't wait for the user at all:
- `confirm` — irreversible yes/no
- `ask` — single- or multi-choice with descriptions and optional free-text fallback
- `form` — composite window with typed fields and multiple action buttons
- `gallery` — batch review of images/videos, one decision per item
- `compare` — side-by-side A/B (or A/B/C) content compare, pick one
+- `notify` — fire-and-forget native OS notification; no dialog, no
+ response, returns immediately
## Default to a dialog, not to chat
@@ -54,6 +57,12 @@ instead:
before choosing one — two drafts, three headlines, before/after an
edit — → `compare`. Don't reach for `ask`+thumbnail here: a thumbnail
is too small to actually compare, `compare` renders the full pane.
+- Any **async-completion signal** the user doesn't need to answer — "tests
+ are green", "deploy finished", "hit a merge conflict, need you" — where
+ the point is exactly that they don't have to be watching this session
+ → `notify`, not a chat message and not `confirm`. If you catch yourself
+ about to end a turn with nothing but "done!" while the user has tabbed
+ away, that's a `notify`, not silence.
## When chat actually wins
@@ -63,7 +72,9 @@ Skip the dialog for content the user reads, doesn't answer:
in chat.
- Single free-text answers where the user would type the same thing into
a dialog box anyway — just ask in chat.
-- Anything where the answer is "go on", and the user is paying attention.
+- Anything where the answer is "go on", and the user is paying attention
+ (i.e. they're actually looking at this session — otherwise see `notify`
+ above).
## Tool choice
@@ -79,9 +90,46 @@ Skip the dialog for content the user reads, doesn't answer:
| Per-item verdict on a *batch* of images/videos ("approve/revise/skip each") | `gallery` |
| Pick one of 2–3 full variants shown side by side (drafts, headlines, before/after) | `compare` |
| Mark *where* on an image (point / region) | `form` with `annotated_image` |
+| Async-completion signal, no reply needed, user may not be watching | `notify` |
| Single free-text answer | just ask in chat |
| More than 8 fields | split into multiple `form` calls; do not cram one dialog |
+## Fire-and-forget: `notify`
+
+`notify` is the odd one out: it does not open a window and does not wait
+for the user. Call it, get `{ok: true}` back immediately, move on. Use it
+for the class of thing you'd otherwise announce in chat and hope the user
+notices — "tests green", "deploy finished", "hit a merge conflict, need
+you" — when they may not be looking at this session at all. That's also
+the line that separates it from `confirm`: if the message expects a reply,
+it's the wrong tool — `notify` has no way to carry an answer back.
+
+Spec: `{title, body, subtitle?, sound?}`. `title` and `body` are required.
+
+- `title` — short headline, notification banners truncate anything past
+ ~40 characters. State the outcome, not the process ("Deploy finished",
+ not "Deploying...").
+- `body` — the detail: what finished, what needs attention, what broke.
+- `subtitle` — optional extra context line (folded into `body` on
+ platforms/backends without a distinct subtitle slot — don't rely on it
+ rendering as a visually separate line).
+- `sound` — optional OS sound name (e.g. `"default"`); omit for a silent
+ notification.
+
+First call triggers the one-time macOS notification-permission prompt,
+same as any other native app — if the user has denied it, `notify`
+returns `{ok: false, error}` rather than erroring the tool call; that's
+an expected outcome, not a bug to retry around.
+
+**Anti-patterns:**
+
+- Using `notify` for anything that expects an answer — that's `confirm`/
+ `ask`/`form`. `notify` is one-way.
+- Firing `notify` for routine intermediate progress ("step 3 of 10") —
+ reserve it for the completion/attention-worthy moment, or every step
+ becomes a notification and the signal drowns.
+- Padding `title` with a full sentence — put the sentence in `body`.
+
## Writing labels and copy
- Imperative or noun, ≤ 6 words per label, no punctuation, no emoji.
diff --git a/python/src/aiui_mcp/server.py b/python/src/aiui_mcp/server.py
index 49a057f..6a605b3 100644
--- a/python/src/aiui_mcp/server.py
+++ b/python/src/aiui_mcp/server.py
@@ -127,6 +127,9 @@ def _default_token_path() -> str:
- User wants to hand you a file from their Mac (`/aiui:upload`, \
"take this file", "upload …") → call `upload` with the target \
directory on your host; don't ask them to `scp` it.
+- Async-completion signal the user doesn't need to answer (tests green, \
+ deploy done, merge conflict) → call `notify` — it returns immediately, \
+ no dialog, no reply expected.
- Pure information the user only reads → keep it in chat.
Type `/aiui:teach` for the full widget catalog when composing a \
@@ -1329,6 +1332,73 @@ async def compare(
return _format_result(await _post_render(spec, ctx, session))
+@mcp.tool()
+async def notify(
+ title: str,
+ body: str,
+ subtitle: str | None = None,
+ sound: str | None = None,
+) -> dict[str, Any]:
+ """Fire a native macOS notification and return immediately — use this
+ for an async-completion signal to a user who isn't watching this
+ session ("tests green", "deploy finished", "merge conflicts, need
+ you"). Unlike `confirm`/`ask`/`form`/`gallery`, this tool does NOT wait
+ for the user: it hands the notification to the OS and returns
+ `{ok: True}` right away, with no dialog, no window, no response to
+ parse.
+
+ WHEN TO USE: the point is exactly that the user doesn't have to be
+ looking at this session to notice — a long-running task just finished,
+ something needs their attention whenever they get to it. Use it
+ instead of a chat message for that case.
+
+ WHEN NOT TO USE: anything that needs an answer (yes/no, a choice,
+ input) — `notify` has no way to carry a reply back. Use `confirm`,
+ `ask`, or `form` instead.
+
+ Runs against the *user's Mac*, regardless of whether this MCP is local
+ or reached via an SSH reverse-tunnel — same as `update`/`version`,
+ the notification always renders on the Mac side.
+
+ Returns `{ok: bool, error?: str}`. `ok: False` most commonly means the
+ user hasn't granted aiui notification permission on macOS yet (the OS
+ prompts for this once, on the first `notify` call) — not a bug to
+ retry around.
+
+ Args:
+ title: Short headline, ≤ ~40 chars — notification banners
+ truncate longer text.
+ body: The detail — what finished, what needs attention.
+ subtitle: Optional extra context line.
+ sound: Optional OS notification sound name (e.g. "default").
+ Omit for silent.
+ """
+ try:
+ async with httpx.AsyncClient(timeout=TIMEOUT_S) as client:
+ r = await client.post(
+ f"{ENDPOINT}/notify",
+ headers={"Authorization": f"Bearer {_token()}"},
+ json={"title": title, "body": body, "subtitle": subtitle, "sound": sound},
+ )
+ if r.status_code == 422:
+ # Structured invalid_request from the companion (e.g. empty
+ # title) — surface detail so the agent can fix the call.
+ try:
+ detail = r.json().get("detail", r.text)
+ except ValueError:
+ detail = r.text
+ raise RuntimeError(f"aiui rejected the notification: {detail}")
+ r.raise_for_status()
+ return r.json()
+ except RuntimeError:
+ raise # our own structured error above — pass through verbatim
+ except Exception as e:
+ raise RuntimeError(
+ f"aiui /notify failed at {ENDPOINT}: {_explain_exc(e)}. "
+ f"Run `aiui_health` first to check whether aiui.app is reachable."
+ ) from e
+
+
@mcp.prompt(name="teach")
def teach_prompt() -> str:
"""Brief the agent on aiui. Loads the full widget catalog, design
diff --git a/python/src/aiui_mcp/skill.md b/python/src/aiui_mcp/skill.md
index 3383620..0de11ee 100644
--- a/python/src/aiui_mcp/skill.md
+++ b/python/src/aiui_mcp/skill.md
@@ -1,15 +1,19 @@
---
name: aiui
-description: Before writing a yes/no question, a numbered option list, or a multi-question request into the chat, open a native macOS dialog instead — `confirm` for yes/no (always for delete/force-push/drop/deploy), `ask` for one-of-N with per-option context, `form` for ≥ 2 related inputs / secrets / dates / sliders / sortable lists / table-row triage / image confirm.
+description: Before writing a yes/no question, a numbered option list, or a multi-question request into the chat, open a native macOS dialog instead — `confirm` for yes/no (always for delete/force-push/drop/deploy), `ask` for one-of-N with per-option context, `form` for ≥ 2 related inputs / secrets / dates / sliders / sortable lists / table-row triage / image confirm, `notify` for a fire-and-forget completion signal that doesn't block on a reply.
---
# aiui — Dialog design for Claude agents
-aiui exposes three MCP tools that render native dialogs on the user's Mac:
+aiui exposes MCP tools that render native dialogs on the user's Mac, plus
+one that doesn't wait for the user at all:
- `confirm` — irreversible yes/no
- `ask` — single- or multi-choice with descriptions and optional free-text fallback
- `form` — composite window with typed fields and multiple action buttons
+- `gallery` — batch review of images/videos with a per-item verdict
+- `notify` — fire-and-forget native OS notification; no dialog, no
+ response, returns immediately
## Default to a dialog, not to chat
@@ -37,6 +41,10 @@ instead:
or generated sound clip** before confirming, choosing, or triaging it
→ `form` with an `audio` field (native `