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 +245 -205
- package/dist/index.js +62 -37
- package/dist/ui/messaging/notificationEngine.js +147 -44
- package/dist/ui/server.js +376 -276
- package/package.json +4 -27
- package/scripts/idle-check.ps1 +47 -0
- package/ui/public/index.html +538 -538
- package/ui/public/style.css +793 -794
- package/dist/channels/desktop.js +0 -48
- package/dist/channels/discord.js +0 -20
- package/dist/channels/email.js +0 -29
- package/dist/channels/ntfy.js +0 -22
- package/dist/channels/pushover.js +0 -26
- package/dist/channels/slack.js +0 -36
- package/dist/channels/sms.js +0 -27
- package/dist/channels/teams.js +0 -43
- package/dist/channels/telegram.js +0 -19
- package/dist/channels/whatsapp.js +0 -11
- package/dist/config.js +0 -16
- package/dist/ui/messaging/types.js +0 -1
package/README.md
CHANGED
|
@@ -1,264 +1,304 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://raw.githubusercontent.com/menih/
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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=
|
|
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/
|
|
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/
|
|
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
|
-
##
|
|
27
|
+
## The two processes
|
|
28
28
|
|
|
29
|
-
|
|
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
|
-
-
|
|
32
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
215
|
-
"
|
|
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
|
-
"
|
|
219
|
-
"
|
|
220
|
-
"
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
| `
|
|
257
|
-
| `GET
|
|
258
|
-
| `GET
|
|
259
|
-
| `GET/POST /api/config` | Read/write the config
|
|
260
|
-
| `POST /api/
|
|
261
|
-
| `
|
|
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
|
|