omni-notify-mcp 1.3.13 → 1.3.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,264 +1,304 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/menih/notify-mcp/main/assets/logo.svg" width="128" height="128" alt="omni-notify-mcp">
2
+ <img src="https://raw.githubusercontent.com/menih/BullseyeNotif/main/assets/logo.svg" width="128" height="128" alt="BullseyeNotify">
3
3
  </p>
4
4
 
5
- <h1 align="center">omni-notify-mcp</h1>
5
+ <h1 align="center">BullseyeNotify · <code>omni-notify-mcp</code></h1>
6
6
 
7
7
  <p align="center">
8
- <em>Reach me on any channel. Ask me anything. Get out of my way when I'm busy.</em><br>
9
- HTTP-first notification/control server with optional MCP compatibility for AI agents
10
- (Claude, Copilot, Cursor, etc.): desktop, Telegram, Slack, SMS, email,
11
- two-way replies, idle gating, and Do Not Disturb.
8
+ A local notification bus for AI agents. One HTTP server owns the channels, the
9
+ routing policy and the inbound message queue; a thin stdio MCP bridge exposes it
10
+ to Claude Code, Cursor, Copilot and any other MCP host; a VS Code extension frames
11
+ the config UI in the editor.
12
12
  </p>
13
13
 
14
14
  <p align="center">
15
15
  <a href="https://www.npmjs.com/package/omni-notify-mcp"><img src="https://img.shields.io/npm/v/omni-notify-mcp.svg" alt="npm"></a>
16
- <a href="https://marketplace.visualstudio.com/items?itemName=Karish911.omni-notify-mcp"><img src="https://img.shields.io/visual-studio-marketplace/v/Karish911.omni-notify-mcp?label=marketplace" alt="VS Code Marketplace"></a>
16
+ <a href="https://marketplace.visualstudio.com/items?itemName=menihillel.bullseye-notify"><img src="https://img.shields.io/visual-studio-marketplace/v/menihillel.bullseye-notify?label=marketplace" alt="VS Code Marketplace"></a>
17
17
  <img src="https://img.shields.io/badge/license-MIT-4ea3ff.svg" alt="MIT license">
18
- <img src="https://img.shields.io/badge/MCP-compatible-4ea3ff.svg" alt="MCP compatible">
18
+ <img src="https://img.shields.io/badge/node-%3E%3D18-4ea3ff.svg" alt="Node 18+">
19
19
  </p>
20
20
 
21
21
  <p align="center">
22
- <img src="https://raw.githubusercontent.com/menih/notify-mcp/main/assets/screenshots/main-ui.png" width="900" alt="omni-notify-mcp config UI — channels and policies side by side, with live activity log">
22
+ <img src="https://raw.githubusercontent.com/menih/BullseyeNotif/main/assets/screenshots/main-ui.png" width="900" alt="Config UI — delivery channels and system policies side by side, with the activity log below">
23
23
  </p>
24
24
 
25
25
  ---
26
26
 
27
- ## Why
27
+ ## The two processes
28
28
 
29
- You step away from your machine and the AI is still working. **It needs to**:
29
+ | Process | Entry | What it owns |
30
+ |---|---|---|
31
+ | **UI/HTTP server** | `dist/ui/server.js` (bin `omni-notify-ui`) | Every channel implementation, the routing policy (DND / idle / mute), the inbound inbox, the Telegram long-poll listener, the Slack channel poller, the built-in ntfy server, the web config UI, and — when `ENABLE_MCP=1` — the Streamable-HTTP `/mcp` endpoint. Listens on `0.0.0.0:3737` by default. |
32
+ | **stdio MCP bridge** | `dist/index.js` (bin `omni-notify-mcp`) | A stateless proxy. Auto-spawns the server if `:3737` isn't answering, opens one persistent `/mcp` session, forwards every tool call to it, subscribes to `/api/inbox/stream` and re-emits each message as a `notifications/claude/channel` event. Holds no config and no queue. |
30
33
 
31
- - tell you something important happened (`notify`)
32
- - ask you a question and wait for your answer (`ask`)
33
- - check whether you've sent it an unsolicited message (`poll`, or live SSE)
34
- - **not buzz your phone every 90 seconds** while you're sitting at the keyboard
34
+ The VS Code extension (`vscode-extension/`) is a third, optional face: it starts the
35
+ server if needed and renders `http://localhost:3737/` inside a webview.
35
36
 
36
- `omni-notify-mcp` is the one MCP server that does all of that, on whatever channels you've configured, with the right level of "shut up" built in.
37
+ > **Build note.** `package.json` is deliberately untracked in this repo (see
38
+ > `.gitignore`), so the dependency list, the `bin` map, the version and the extension
39
+ > manifest are not in git. The published package declares `build` as
40
+ > `tsc && tsc -p ui/tsconfig.json` and `test` as `npm run build && node --test tests/*.test.mjs`.
37
41
 
38
42
  ## Quick start
39
43
 
40
44
  ```bash
41
- npx omni-notify-ui
45
+ npm install
46
+ npm run build
47
+ ENABLE_MCP=1 node dist/ui/server.js # or: ./notify-ui.sh (no MCP)
42
48
  ```
43
49
 
44
- This starts the HTTP/UI server on `http://localhost:3737`.
50
+ Open <http://localhost:3737>, enable the channels you want, hit the per-channel
51
+ **Test** button. Config is written to `~/.notify-mcp/config.json` and re-read on
52
+ every notification — no restart after a settings change.
45
53
 
46
- MCP is optional. To enable the `/mcp` endpoint, start with:
54
+ Register the bridge with an MCP host:
47
55
 
48
- ```bash
49
- ENABLE_MCP=1 npx omni-notify-ui
50
- ```
51
-
52
- Or use the stdio bridge (which auto-spawns the UI with MCP enabled):
53
-
54
- ```bash
55
- npx omni-notify-mcp
56
- ```
57
-
58
- Add to your MCP config (`~/.claude.json`, `.vscode/mcp.json`, `claude_desktop_config.json`, etc.):
59
-
60
- ```json
61
- {
62
- "mcpServers": {
63
- "notify": {
64
- "command": "npx",
65
- "args": ["omni-notify-mcp"]
66
- }
67
- }
68
- }
69
- ```
70
-
71
- Open <http://localhost:3737>, toggle the channels you want, and hit Save. The MCP server picks up changes immediately — no restart.
72
-
73
- ## What the agent gets
74
-
75
- Eight tools, all server-configured (the agent never names a channel):
76
-
77
- | Tool | What it does |
78
- |---|---|
79
- | **`notify`** | Send a message to the user. Priority controls fan-out (see below). |
80
- | **`ask`** | Send a question and **wait** for the user's reply (Telegram, or web link via email). |
81
- | **`poll`** | Drain any unsolicited messages the user sent. |
82
- | **`wait_for_inbox`** | **Long-poll**: block up to 55s and return the moment the user types something. The most reliable push path across every MCP client — messages come back as tool *results*, not notifications (which many clients drop). |
83
- | **`get_idle_seconds`** | Seconds since last keyboard/mouse input. Drains inbox as a side-effect. |
84
- | **`get_idle_config`** | The server's idle-gating policy `{ enabled, thresholdSeconds }`. Drains inbox. |
85
- | **`get_dnd_status`** | Current DND state `{ active, reason }`. Drains inbox. |
86
- | **`reply`** *(stdio only)* | Channels return-path — Claude Code calls this when the agent responds to a pushed channel message. Routes straight through `notify`. |
87
-
88
- Priority routing for `notify`:
89
-
90
- | Priority | Channels |
91
- |---|---|
92
- | `low` | email only |
93
- | `normal` | desktop + Telegram + email |
94
- | `high` | desktop + Telegram + SMS + email — **bypasses DND and idle gating** |
95
-
96
- ## Features
97
-
98
- ### Channels
99
- - **Desktop** — native `node-notifier` (macOS/Windows/Linux). Per-channel **system-sound toggle** and optional **text-to-speech** with a voice picker covering 30+ neural voices (US/UK/AU/CA/IN/IE/NZ/…) via `msedge-tts`, no API key.
100
- - **Telegram** — bidirectional. The bot **replies in-thread** to user messages and acknowledges every inbound message so the user knows it landed.
101
- - **SMS** — Twilio.
102
- - **Email** — Gmail App Password (one click) or any SMTP. `ask` over email sends a reply link the user clicks to answer.
103
-
104
- ### Two-way (`ask`)
105
- The agent calls `ask`, the question goes out on Telegram and email, and the call **blocks until the user replies** (or times out). Reply on Telegram → agent gets the text. Click the email reply link → agent gets the text. No glue code, no polling loop.
106
-
107
- ### Real-time inbox push (SSE)
108
- Subscribe to `GET /api/inbox/stream` (text/event-stream) to receive unsolicited user messages **the moment they arrive** — no polling. Ideal for an always-on agent that wants to react instantly. Per-session tag filtering supported (`?tag=alphawave`). Falls back gracefully to `poll` for clients without SSE.
109
-
110
- ### Multi-session tagging
111
- Run multiple agents against the same notify server (e.g. one Claude session in `repo-a`, another in `repo-b`). Each connects with `?tag=<name>` and the user can route a Telegram message to a specific agent by prefixing `@<name>`. Untagged messages broadcast to every session.
112
-
113
- The stdio bridge derives its tag as `<hostname>-<workspace-folder>` unless you override it with the **`NOTIFY_MCP_TAG`** env var — set it to give a bridge a durable, human-meaningful name (e.g. `NOTIFY_MCP_TAG=frontend`). This is the only stable per-bridge name: Claude Code exposes no per-session id to MCP subprocesses, so two extension panels on the *same* workspace share a tag by default.
114
-
115
- ### Multiple panels in one window
116
- Open N Claude extension panels in one VS Code window and each spawns its own bridge → its own MCP session. They share a tag (same host + workspace), but `/api/clients` lists each as a separate logical client (one per panel), disambiguated by an auto-suffixed id (`foo`, `foo-2`, …) plus `panel <n>/<total>` and a short session id. `list clients` annotates the panel count. To give a panel its own durable identity instead of an ordinal, launch it with a distinct `NOTIFY_MCP_TAG`.
117
-
118
- ### Do Not Disturb
119
- - **Manual toggle** — flip it on, all `priority < high` notifs drop on the floor.
120
- - **Scheduled quiet hours** — e.g. 22:00 → 08:00, configurable per-day.
121
- - `priority='high'` always punches through.
122
- - Agents can pre-flight with `get_dnd_status` to skip the round-trip when DND is on.
123
-
124
- ### Heartbeat-drain (stay responsive during long work)
125
- Every agent that calls `get_idle_seconds` or `get_dnd_status` while busy gets any pending user inbox messages piggy-backed on the response. The server-side MCP `instructions` tell agents to call `get_idle_seconds` every 15-30 seconds during long operations so a user ping from Telegram lands within 30 seconds even if the agent hasn't called `notify` in hours. When an inbox message lands, the agent is required to fire a terse `busy-ack` back via `notify` so the user knows they were heard — even if the full response comes later.
126
-
127
- ### Multi-session broadcast
128
- When multiple agents connect to the same server (e.g. one Claude per repo), every untagged user message is broadcast to all of them. Each agent replies with its session id, the user picks who they want to address, then targets follow-ups with `@<tag>`. The Telegram ack names the sessions the message was routed to.
129
-
130
- ### Dual transport — HTTP and stdio (with Claude Code Channels)
131
- `notify-mcp` ships two entrypoints against the same server state:
132
-
133
- - **`omni-notify-mcp`** (stdio) — the default `npx omni-notify-mcp` command. Speaks stdio JSON-RPC, auto-spawns the HTTP server as a detached child if it isn't already running, and subscribes to the inbox SSE stream so it can push unsolicited messages to the attached agent. **Declares the `claude/channel` capability**, so Claude Code v2.1.80+ surfaces each user message as a synthetic turn via `notifications/claude/channel` — the only push path that crosses the client boundary reliably ([modelcontextprotocol#1192](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/1192), [claude-code/channels](https://code.claude.com/docs/en/channels)). When the host doesn't support Channels, the bridge still works — agents just use `wait_for_inbox` as the long-poll fallback.
134
- - **`omni-notify-ui`** (HTTP, default `:3737`) — runs the config web UI, the Telegram listener, all channel implementations, and the Streamable-HTTP `/mcp` endpoint for remote / multi-session agents.
135
-
136
- For Claude Code with Channels:
137
- ```bash
138
- claude --channels omni-notify-mcp
139
- # or, during preview, if your plugin isn't allowlisted:
140
- claude --dangerously-load-development-channels omni-notify-mcp
141
- ```
142
-
143
- For every other MCP client, the stdio command works as a plain MCP server:
144
56
  ```json
145
57
  {
146
58
  "mcpServers": {
147
- "notify": { "command": "npx", "args": ["omni-notify-mcp"] }
148
- }
149
- }
150
- ```
151
-
152
- ### Reliable push — `wait_for_inbox` long-poll
153
- The hard truth: **most MCP clients silently drop generic server notifications** ([modelcontextprotocol#1192](https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/1192), [claude-code#41733](https://github.com/anthropics/claude-code/issues/41733)). The only delivery paths that survive are (a) Claude Code's new Channels (`notifications/claude/channel`, handled by the stdio bridge above) and (b) tool *results*. `wait_for_inbox` is the universal fallback: the agent calls it, the server parks the request until the user types something, then resolves it with the message — which the client is forced to surface because it's a tool-call response. Default timeout is 50s to stay under the 60s JS SDK request ceiling ([typescript-sdk#245](https://github.com/modelcontextprotocol/typescript-sdk/issues/245)); the agent re-calls on empty in a tight loop. The MCP `instructions` block shipped with the server tells agents exactly this loop pattern, so no per-prompt nagging is needed.
154
-
155
- ### Reconnect resilience
156
- The server returns HTTP 404 on requests with a stale `mcp-session-id` (per the MCP Streamable HTTP spec), so a client that wakes up after a server restart automatically re-initializes on its next tool call instead of staying stuck with a dead session. Idle sessions are reaped aggressively (90-second timeout, since the heartbeat contract requires requests every 15–30s), dead SSE subscribers are pruned every 15s, and every broadcast ack runs a liveness probe before counting targets — so "Broadcast to N session(s)" always reflects who can actually receive. TCP keepalive is enabled on every incoming socket (15s probe) and the server writes an SSE `: keepalive` comment down every live MCP GET stream every 20s, which defeats proxy idle timeouts and surfaces half-open connections within a minute ([typescript-sdk#270](https://github.com/modelcontextprotocol/typescript-sdk/issues/270)). The stdio bridge transparently re-initializes a fresh HTTP session on 404 without the agent ever seeing a failure.
157
-
158
- ### File-drop bridge for busy agents (the /btw mechanism)
159
- Claude Code has no public API for injecting a prompt into a running session while a tool call is executing ([anthropics/claude-code#27441](https://github.com/anthropics/claude-code/issues/27441)). The MCP heartbeat-drain already handles agents that are *voluntarily polling*, but if the agent is deep in a 5-minute `Bash` or `WebFetch` call, the piggy-back never fires. For that case, the server drops every unsolicited inbox message as a markdown file at `~/.notify-mcp/inbox/<timestamp>.md`, so a Claude Code `FileChanged` hook can surface it on the very next turn without the agent having to cooperate.
160
-
161
- Drop this into `~/.claude/settings.json` (or a project-local `.claude/settings.json`):
162
-
163
- ```json
164
- {
165
- "hooks": {
166
- "FileChanged": [
167
- {
168
- "matcher": "**/.notify-mcp/inbox/*.md",
169
- "hooks": [
170
- {
171
- "type": "command",
172
- "command": "cat \"$CLAUDE_FILE_PATH\" && rm \"$CLAUDE_FILE_PATH\""
173
- }
174
- ]
175
- }
176
- ]
59
+ "notify": { "type": "stdio", "command": "npx", "args": ["-y", "omni-notify-mcp"] }
177
60
  }
178
61
  }
179
62
  ```
180
63
 
181
- The hook's stdout is injected as additional context on the next turn, and the `rm` clears the drop so each message fires exactly once. Stale drops older than 24h are reaped by the server automatically.
182
-
183
- ### Idle gating (anti-buzz)
184
- The server publishes a policy `{ enabled, thresholdSeconds }`. Agents are **instructed** (via the MCP `instructions` field, surfaced to every connecting client) to call `get_idle_seconds` first, and **skip** sending a notification if you're actively at the keyboard. They can already see what they'd send. Only fire when you've stepped away. `priority='high'` always fires.
185
-
186
- Cross-platform idle detection: Windows (PowerShell + `GetLastInputInfo`), macOS (`ioreg`), Linux (`xprintidle`).
187
-
188
- ### Web config UI
189
- One page, dark theme, live activity log streaming over SSE, one-click test buttons per channel, secrets masked at rest. Plus a copy-paste help page that walks any AI client through registration in 30 seconds:
190
-
191
- <p align="center">
192
- <img src="https://raw.githubusercontent.com/menih/notify-mcp/main/assets/screenshots/help-page.png" width="800" alt="Help page — copy-paste snippets for Claude Code, Cursor, VS Code, Claude Desktop, Windsurf, Zed">
193
- </p>
194
-
195
- ### Activity log
196
- Every notify, ask, reply, and inbox event is logged with timestamp, direction (`→` `←` `·`), channel, and (color-coded) client/session id. Visible live in the UI; last 500 entries replayed on connect.
197
-
198
- ### Behavioral rules baked in
199
- The MCP server ships with `instructions` that tell every connecting client:
200
- 1. Pre-flight with `get_idle_seconds` and skip if user is active.
201
- 2. Echo the message in chat too don't trust the user is checking their phone.
202
- 3. Use channel-agnostic wording ("notif", not "Telegram").
203
- 4. Reply to inbox messages **through `notify`**, not just in chat.
204
- 5. `priority='high'` is for blockers, not noise.
205
-
206
- This means well-behaved agents get the right behavior automaticallyno per-prompt nagging required.
64
+ VS Code / Copilot use `.vscode/mcp.json` with a `servers` key instead of
65
+ `mcpServers`; the bundled help page at `http://localhost:3737/help.html` has the exact
66
+ snippet for Claude Code, Cursor, VS Code, Claude Desktop, Windsurf, Zed and generic
67
+ clients.
68
+
69
+ ## MCP tools
70
+
71
+ Eight tools over HTTP, nine over stdio. The agent never names a channel — routing is
72
+ entirely server-side.
73
+
74
+ | Tool | Arguments | Behaviour |
75
+ |---|---|---|
76
+ | `notify` | `message`, `priority` = `low`\|`normal`\|`high` | Fan out per the routing table below. Response states `outcome=` with the `attempted` and `delivered` channels plus any per-channel error, and carries any queued inbox messages for this session. |
77
+ | `ask` | `question`, `timeout_seconds` (30–3600, default 300) | Sends the question to Telegram (every configured chat id) and email (with a one-shot `/reply/:token` web page), then **blocks** until a reply lands or the timeout throws. |
78
+ | `poll` | — | Drains queued inbox messages for this session's tag; returns `inbox:empty` when there are none. |
79
+ | `wait_for_inbox` | `timeout_seconds` (5–55, default 50) | Parks a waiter and returns the moment a matching message arrives, as a tool *result*. Returns already-queued messages immediately without parking. |
80
+ | `get_idle_seconds` | — | Seconds since the last OS keyboard/mouse input, or `-1` where unsupported. Drains the inbox as a side effect. |
81
+ | `get_idle_config` | | `{ enabled, thresholdSeconds, alwaysDesktopWhenActive }`. Drains the inbox. |
82
+ | `get_dnd_status` | | `{ active, reason }` where reason is `disabled` (master mute or this client disabled), `manual`, `schedule` or `off`. Drains the inbox. |
83
+ | `update_instructions` | `instructions` (≤4000), `target` = `global`\|`project` | Rewrites a marker-delimited block in `~/.claude/CLAUDE.md` (global) or `./.claude/CLAUDE.md` (project). |
84
+ | `reply` *(stdio bridge only)* | `message` (≤2000), `priority` | The Claude Code Channels return path. Prefixes the bridge's session tag and routes through `notify`. |
85
+
86
+ **Message size and chunking.** The HTTP server caps `notify.message` at 500
87
+ characters and rejects anything longer. The stdio bridge accepts up to 5000, splits it
88
+ in `src/chunk.ts` into `(1/N) …` chunks of ≤500 (word-aware where a break lands past
89
+ 60 % of the budget), and delivers them in order so chunking exists **only on the
90
+ bridge path**. A bridge caller gets back either `Delivered as N chunks (M chars).` or a
91
+ count of how many chunks reached no channel and what the server said.
92
+
93
+ ## Delivery routing
94
+
95
+ `sendWithRouting` in `ui/messaging/notificationEngine.ts` is the single chokepoint.
96
+ Three suppressions run before it, in order: master mute (`muteAll`) drops everything
97
+ including `high`; a client listed in `disabledClients` is dropped by client id; then
98
+ DND drops everything below `high`.
99
+
100
+ The **desktop-only** mode kicks in when the web UI reported itself visible in the last
101
+ 30 s, or when idle gating is on, no Telegram conversation has been active in the last
102
+ 5 minutes, and OS idle time is under the threshold.
103
+
104
+ > With `idle.alwaysDesktopWhenActive: false` the desktop is held back too while the user
105
+ > is active, so only Slack is attempted. When Slack is off nothing is sent, and the reply
106
+ > says `outcome=blocked`, as it does under DND, never `outcome=failed`.
107
+
108
+ | Channel | `low` | `normal` | `high` | Under desktop-only |
109
+ |---|---|---|---|---|
110
+ | desktop | — | ✓ | ✓ | ✓ |
111
+ | telegram | — | ✓ | ✓ | — |
112
+ | email | ✓ | ✓ | ✓ | — |
113
+ | ntfy | ✓ | ✓ | ✓ | — |
114
+ | discord | ✓ | ✓ | ✓ | — |
115
+ | teams | ✓ | ✓ | ✓ | — |
116
+ | slack | ✓ | ✓ | ✓ | **✓ — exempt from idle gating** |
117
+ | sms | — | — | ✓ | — |
118
+
119
+ `priority: 'high'` bypasses DND and idle gating both. Per-channel failures are
120
+ collected, never thrown: the tool response lists what was delivered and what errored.
121
+
122
+ ## Channels
123
+
124
+ Every channel is off until enabled, and a channel whose credentials are incomplete is
125
+ skipped by the `enable*` predicates in `ui/server.ts` even when `enabled: true`.
126
+
127
+ | Channel | Config key and fields | Transport |
128
+ |---|---|---|
129
+ | **Desktop** | `desktop` — `enabled`, `sound`, `tts`, `ttsVoice` | `node-notifier` toast titled *Claude Notify*. On Windows the toast sound is unreliable, so a PowerShell `SystemSounds::Asterisk` is fired alongside it. With `tts: true` the body is synthesised through `msedge-tts` (default voice `en-US-AndrewMultilingualNeural`) and played via MediaPlayer / `afplay` / `aplay`. |
130
+ | **Telegram** | `telegram` — `enabled`, `token`, `chatIds[]` | `sendMessage` per chat id. When the last inbound message came from that same chat, the notification is sent as a threaded reply. Also the inbound listener (see below). |
131
+ | **Email** | `email` — `enabled`, `to`, plus either Gmail OAuth (`clientId`, `clientSecret`, `refreshToken`, `accessToken`, `connectedEmail`) or SMTP (`host`, `port` 587, `secure` false, `user`, `pass`) | `nodemailer`; subject *Claude Notify*. OAuth wins when a refresh token is present. `ask` sends HTML with a **Reply to Claude** button pointing at `http://<lan-ip>:3737/reply/<token>`. |
132
+ | **SMS** | `sms` — `enabled`, `accessKeyId`, `secretAccessKey`, `region`, `originationNumber`, `to[]` | AWS End User Messaging (`@aws-sdk/client-pinpoint-sms-voice-v2`, `SendTextMessage`), one call per destination, numbers normalised to E.164. `high` priority only. |
133
+ | **ntfy** | `ntfy` — `enabled`, `topic`, `serverUrl` (display only), `token` | **This server *is* the ntfy server.** It implements the publish/subscribe wire protocol at `/:topic/sse`, `/:topic/json` and `PUT`/`POST /:topic`, and a notification fans out to whoever is subscribed to that topic *on this server*. Point the ntfy mobile app at this host. Sending with zero subscribers is an error, not a silent drop. |
134
+ | **Discord** | `discord` — `enabled`, `webhookUrl`, `username`, `clientId`, `clientSecret`, `channelName` | Webhook embed, colour-coded by priority. `GET /auth/discord/start` runs a one-click OAuth flow (`webhook.incoming` scope) that returns a ready-made channel webhook. |
135
+ | **Slack** | `slack` — `enabled`, `webhookUrl`, `botToken`, `channels[]`, `clientId`, `clientSecret`, `team` | With a bot token **and** channels, one `chat.postMessage` per channel; otherwise the single incoming webhook. `GET /auth/slack/start` runs OAuth for `channels:read,groups:read,channels:history,groups:history,chat:write,chat:write.public`, so the minted token also serves the Slack bus poller. When `slack.botToken` is blank the token from `notify-secrets.json` / `SLACK_BOT_TOKEN` is reused. |
136
+ | **Teams** | `teams` — `enabled`, `webhookUrl` | Adaptive Card v1.2 posted to a Teams *Workflows* webhook. |
207
137
 
208
138
  ## Configuration
209
139
 
210
- Default location: `~/.notify-mcp/config.json`. The web UI manages this file for you, but the schema is straightforward:
140
+ `~/.notify-mcp/config.json`, overridable with `NOTIFY_MCP_CONFIG_DIR`. The UI writes
141
+ it; secrets are replaced with `••••••••` on read and a masked value posted back is
142
+ restored from the on-disk copy rather than overwriting it. Legacy single-destination
143
+ fields are migrated on load: `telegram.chatId` → `chatIds[]`, `sms.to` string →
144
+ array, and the dead Twilio fields (`accountSid`, `authToken`, `from`) are deleted.
211
145
 
212
146
  ```json
213
147
  {
214
- "desktop": { "enabled": true, "sound": true },
215
- "telegram": { "enabled": true, "token": "BOT_TOKEN", "chatId": "CHAT_ID" },
148
+ "muteAll": false,
149
+ "disabledClients": [],
150
+ "clientAliases": {},
151
+ "desktop": { "enabled": true, "sound": true, "tts": false },
152
+ "telegram": { "enabled": true, "token": "BOT_TOKEN", "chatIds": ["123456789"] },
216
153
  "sms": {
217
154
  "enabled": false,
218
- "accountSid": "ACxxxx",
219
- "authToken": "...",
220
- "from": "+15550000000",
221
- "to": "+15550000001"
155
+ "accessKeyId": "AKIA…", "secretAccessKey": "…",
156
+ "region": "us-east-1", "originationNumber": "+15550000000",
157
+ "to": ["+15550000001"]
222
158
  },
223
159
  "email": {
224
- "enabled": true,
160
+ "enabled": true, "to": "you@gmail.com",
225
161
  "host": "smtp.gmail.com", "port": 587, "secure": false,
226
- "user": "you@gmail.com", "pass": "GMAIL_APP_PASSWORD",
227
- "to": "you@gmail.com"
162
+ "user": "you@gmail.com", "pass": "GMAIL_APP_PASSWORD"
228
163
  },
164
+ "ntfy": { "enabled": false, "topic": "", "serverUrl": "" },
165
+ "discord": { "enabled": false, "webhookUrl": "", "username": "Claude Notify" },
166
+ "slack": { "enabled": false, "webhookUrl": "", "botToken": "", "channels": [] },
167
+ "teams": { "enabled": false, "webhookUrl": "" },
229
168
  "dnd": {
230
169
  "enabled": false,
231
- "schedule": {
232
- "enabled": false,
233
- "quietStart": "22:00", "quietEnd": "08:00",
234
- "days": [0, 1, 2, 3, 4, 5, 6]
235
- }
170
+ "schedule": { "enabled": false, "quietStart": "22:00", "quietEnd": "08:00", "days": [0,1,2,3,4,5,6] }
236
171
  },
237
- "idle": { "enabled": true, "thresholdSeconds": 120 }
172
+ "idle": { "enabled": true, "thresholdSeconds": 120, "alwaysDesktopWhenActive": true }
238
173
  }
239
174
  ```
240
175
 
241
- Disabled channels are silently skipped. Secrets are masked when read back via the API.
242
-
243
- ### Channel setup
244
-
245
- - **Desktop** — works out of the box. Toggle `sound` to mute the system chime.
246
- - **Telegram**create a bot via [@BotFather](https://t.me/botfather), then click **Detect** in the UI to auto-fill the chat ID.
247
- - **SMS** — Twilio account SID + auth token + a Twilio number.
248
- - **Email** Gmail [App Password](https://myaccount.google.com/apppasswords) (the UI walks you through it) or any SMTP host/user/pass.
249
-
250
- ## Endpoints (for power users)
251
-
252
- The UI server (default `:3737`) also exposes:
176
+ `notify-secrets.json` at the repo root is a second, optional source: values under keys
177
+ ending in `_b64` are base64-decoded at load, and on startup any credential it holds is
178
+ copied into **empty** fields of `config.json` (never over a value you already set).
179
+ See `config.example.json` for a hand-written starting point.
180
+
181
+ ## Inbound — the inbox
182
+
183
+ Messages arriving *from* the user (Telegram reply, Slack channel post,
184
+ `POST /api/agent/inbox/inject`, Slack Events API) land in one queue and are delivered
185
+ through whichever path is available:
186
+
187
+ 1. **Parked long-poll waiters** get first refusal — `wait_for_inbox` or
188
+ `GET /api/agent/inbox/wait`, delivered as a tool/HTTP result. Message is handed to
189
+ the waiters and not queued.
190
+ 2. **SSE broadcast** to every `/api/inbox/stream` subscriber whose tag matches. The
191
+ stdio bridge is such a subscriber and re-emits each message as a
192
+ `notifications/claude/channel` event, which Claude Code (CLI, launched with
193
+ `--channels`) surfaces as a synthetic turn.
194
+ 3. **File drop** at `~/.notify-mcp/inbox/<iso-ts>[.<tag>].md`, reaped after 24 h. The
195
+ Slack-origin drop embeds the exact `curl` needed to answer in-channel.
196
+ `.claude/notify-inbox-drain.sh` is the consumer shipped here: it is wired into
197
+ `SessionStart`, `UserPromptSubmit` and `Stop`, matches this session's tag, deletes
198
+ each file as it reads it, and returns the text as `additionalContext` (or as a
199
+ `decision: "block"` reason on `Stop`, which is what lets a message reach an agent
200
+ mid-loop). It deliberately no-ops on `PreToolUse`/`PostToolUse`, where Claude Code
201
+ discards hook output — draining there would delete a message without delivering it.
202
+
203
+ **Tags.** A session declares one via `/mcp?tag=<name>` or `/api/inbox/stream?tag=<name>`.
204
+ A user message prefixed `@<tag> ` is routed to that tag only; everything else
205
+ broadcasts. The bridge derives its own tag as
206
+ `<hostname>-<project>` where `<project>` is `NOTIFY_MCP_TAG`, else the nearest
207
+ meaningful folder walking up from `CLAUDE_PROJECT_DIR` (generic names like `claude`,
208
+ `code`, `src`, `dist`, `system32` are skipped). When neither variable is set — the VS
209
+ Code extension case — a 6-character hash of `CLAUDE_CODE_SESSION_ID` is appended so two
210
+ windows sharing a working directory stay distinct. Subagents inherit their parent's
211
+ session id, so `/api/clients` folds them into the parent's panel.
212
+
213
+ Telegram acknowledges every inbound message in-thread, naming what it did:
214
+ routed to `@tag`, broadcast to *n* listeners, or queued because nobody is connected.
215
+
216
+ ## Slack bus
217
+
218
+ A single shared Slack channel works as a cross-machine bus: the server polls
219
+ `conversations.history` every 2 s, ingests genuine human messages only, answers
220
+ `clients` / `help` in-channel itself, and routes `@<name> …` / `#<n> …` to the matching
221
+ connected client. Full architecture, addressing rules and operating notes:
222
+ [`docs/SLACK-BUS.md`](docs/SLACK-BUS.md).
223
+
224
+ ## Web UI
225
+
226
+ One page at `:3737`, dark, no build step. A master-mute bar on top; a **Delivery
227
+ channels** column (desktop, email, telegram, sms, ntfy, discord, slack, teams — each a
228
+ collapsible card with a status badge, an enable toggle, a **Test** button and inline
229
+ setup instructions); a **System policies** column (Do Not Disturb manual + scheduled
230
+ quiet hours with per-day checkboxes; idle gating threshold and the
231
+ always-play-desktop-sound toggle). Below them a collapsible panel with two tabs: the
232
+ live activity log (SSE, last 500 lines replayed on connect, direction-marked `→ ← ·`)
233
+ and **Clients**, which lists every connected session with its panel number and lets you
234
+ rename, disable or force-reconnect one. `help.html` is the copy-paste setup page.
235
+
236
+ ## HTTP endpoints
253
237
 
254
238
  | Path | Purpose |
255
239
  |---|---|
256
- | `POST /mcp[?tag=<name>]` | StreamableHTTP MCP transport. Optional session tag. |
257
- | `GET /api/inbox/stream[?tag=<name>]` | SSE push of unsolicited user messages. |
258
- | `GET /api/logs` | SSE stream of the activity log. |
259
- | `GET/POST /api/config` | Read/write the config (secrets masked on read). |
260
- | `POST /api/test/<channel>` | One-shot test send for desktop/telegram/sms/email. |
261
- | `GET /reply/:token` | Web reply page for `ask` over email. |
240
+ | `ALL /mcp[?tag=&hsid=]` | Streamable-HTTP MCP transport. `404 mcp_disabled` unless `ENABLE_MCP=1`. An unknown session id on a fresh `initialize` is adopted (survives a server restart); on any other request it returns 404 so the client reinitialises. |
241
+ | `GET /api/inbox/stream[?tag=]` | SSE push of inbound user messages, 20 s keepalive comments. |
242
+ | `GET /api/logs` | SSE activity log. |
243
+ | `GET/POST /api/config` | Read (masked) / merge-write the config. |
244
+ | `GET/POST /api/mute` | Master kill switch. |
245
+ | `POST /api/window/register` | Extension server: `{sessionId, workspaceName, workspacePath}`, so a window's real workspace name can be shown for its bridge session. |
246
+ | `GET/POST /api/window/:sessionId/mute` | Mute every client belonging to one editor window. |
247
+ | `GET /api/sessions`, `DELETE /api/sessions/:clientId` | Raw MCP session list / drop one. |
248
+ | `GET /api/clients` | Connected clients with panel numbering, kinds (`mcp`/`sse`/`waiter`), workspace name and disabled flag. |
249
+ | `POST /api/clients/:tag/rename`, `/api/clients/:id/disable` | Persisted display alias / per-client kill switch. |
250
+ | `POST /api/clients/:tag/reconnect`, `/api/clients/:tag/panel/:sessionId/reconnect` | Drop every connection for a tag, or just one panel. |
251
+ | `POST /api/test/{sound,tts,desktop,telegram,sms,email,ntfy,discord,slack,teams}` | One-shot test send per channel. |
252
+ | `GET /api/voices` | English neural voices from msedge-tts, cached 24 h. |
253
+ | `GET /api/telegram/chats`, `/api/slack/channels`, `/api/sms/numbers` | Pickers backing the UI. |
254
+ | `GET /api/slack/status`, `/api/discord/status`, `/api/gcloud/status` | Connection state for the UI badges. |
255
+ | `GET /auth/{google,slack,discord}/start`, `/callback`; `DELETE /auth/{google,slack,discord}` | OAuth connect / disconnect. |
256
+ | `GET /api/google/open-apppasswords`, `POST /api/google/apppassword`, `GET /api/gcloud/login` | Gmail App Password helper flow and the gcloud sign-in it can fall back on. |
257
+ | `POST /api/agent/notify`, `GET /api/agent/inbox/poll`, `GET /api/agent/inbox/wait`, `POST /api/agent/inbox/inject`, `POST /api/agent/slack/reply` | Plain-HTTP agent API — same behaviour as the MCP tools, no transport required. Gated by `x-notify-key` when `NOTIFY_AGENT_KEY` is set. |
258
+ | `POST /api/session/state` | A session reports itself busy/idle so the bus can answer "Claude is busy, ~Ns". |
259
+ | `POST /api/slack/events` | Slack Events API receiver; HMAC-verified when `SLACK_SIGNING_SECRET` is set. |
260
+ | `POST /api/ui/visibility` | The web UI heartbeats its own visibility; while visible, non-`high` notifications stay desktop-only. |
261
+ | `GET /reply/:token`, `POST /reply/:token` | One-shot web reply page for `ask` over email. |
262
+ | `GET /v1/health`, `/v1/info` | Liveness (used by the VS Code extension) and version stub. |
263
+ | `/:topic/sse`, `/:topic/json`, `PUT`/`POST /:topic`, `/:topic/subscribers` | Built-in ntfy protocol server (also mounted under `/ntfy/…`). |
264
+
265
+ `POST /__test__/inject-inbox` and `GET /__test__/slack-clients` exist only when
266
+ `NOTIFY_MCP_TEST_ENDPOINTS=1`.
267
+
268
+ ## Environment
269
+
270
+ | Variable | Read by | Effect |
271
+ |---|---|---|
272
+ | `PORT` / `NOTIFY_MCP_PORT` | server / bridge | Listen port, default 3737. |
273
+ | `ENABLE_MCP` | server | `1` enables `/mcp`. The bridge always sets it when it spawns the server; a manual relaunch that forgets it makes every MCP tool 404. |
274
+ | `NOTIFY_MCP_CONFIG_DIR` | server | Config + inbox-drop root, default `~/.notify-mcp`. |
275
+ | `NOTIFY_MCP_TAG` | bridge, `notify-watch.sh`, drain hook | Explicit session tag. |
276
+ | `NOTIFY_AGENT_KEY` | server | When set, `/api/agent/*` requires header `x-notify-key`. |
277
+ | `SLACK_BOT_TOKEN`, `SLACK_CHANNEL_ID`, `SLACK_POLL_INTERVAL`, `SLACK_SIGNING_SECRET` | server | Slack bus credentials, poll interval (seconds, default 2) and Events-API signature verification. |
278
+ | `NOTIFY_MCP_NO_OPEN`, `BROWSER=none` | server | Suppress the first-run browser pop. |
279
+ | `NOTIFY_MCP_TEST_ENDPOINTS` | server | `1` enables the test-only routes, isolates the inbox drop dir, and skips the live Telegram/Slack listeners. |
280
+ | `CLAUDE_PROJECT_DIR`, `CLAUDE_CODE_SESSION_ID` | bridge, extension | Supplied by the host; drive tag derivation and subagent folding. |
281
+
282
+ ## Scripts
283
+
284
+ | Script | What it does |
285
+ |---|---|
286
+ | `notify-ui.sh` | Build if `dist/ui/server.js` is missing, then run the server in the foreground (**without** `ENABLE_MCP`). |
287
+ | `restart.sh` | `npm run build:ui`, kill the running `dist/ui/server.js`, relaunch detached to `/tmp/notify-mcp.log`. |
288
+ | `bus-up.sh` | Singleton watchdog: keeps `:3737` listening, redeploys when `dist/ui/server.js` changes on disk, and keeps one `notify-watch.sh` alive. Logs to `.run/`. |
289
+ | `notify-watch.sh` | Detached responder. Long-polls `/api/agent/inbox/wait` for `<host>-<folder>-bot`, and passes every message **verbatim** to a headless `claude -p` that answers via `POST /api/agent/slack/reply`. |
290
+ | `scripts/agent-notify.sh`, `agent-poll-inbox.sh`, `agent-wait-inbox.sh`, `agent-listen-loop.sh` | curl wrappers over the plain-HTTP agent API (`NOTIFY_BASE_URL`, `NOTIFY_AGENT_KEY`, `NOTIFY_TAG`, `NOTIFY_ON_MESSAGE_CMD`). |
291
+ | `scripts/bus-startup-task.sh` | Registers `bus-up.sh` with Windows Task Scheduler. |
292
+ | `scripts/run-tunnel-loop.sh` | Keeps a localtunnel pointed at the port. |
293
+ | `release.sh`, `setup-secrets.sh` | npm + Marketplace publish, and the guided credential setup it calls on first run. |
294
+
295
+ ## Tests
296
+
297
+ `tests/smoke.test.mjs` — end-to-end only. It spawns a real server on a random free
298
+ port with an isolated config dir, drives it over real HTTP, and runs the stdio bridge
299
+ as a real subprocess; a local capture server stands in for the Slack webhook. Run with
300
+ `npm test` (which builds first) or `node --test tests/smoke.test.mjs` against an
301
+ existing `dist/`.
262
302
 
263
303
  ## License
264
304