watchline-hermes-plugin 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Watchline
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: watchline-hermes-plugin
3
+ Version: 0.1.0
4
+ Summary: Hermes Agent delivery adapter for Watchline.
5
+ Author-email: Watchline <support@qordinate.ai>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://watch.qordinate.ai
8
+ Project-URL: Documentation, https://watch.qordinate.ai/docs/hermes
9
+ Project-URL: Repository, https://github.com/qordinate-ai/watchline-hermes-plugin
10
+ Project-URL: Issues, https://github.com/qordinate-ai/watchline-hermes-plugin/issues
11
+ Keywords: watchline,hermes-agent,agents,mcp,plugin,pull-delivery
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Communications
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: PyYAML>=6
25
+ Dynamic: license-file
26
+
27
+ # Watchline for Hermes Agent
28
+
29
+ Watchline is an event layer for agents. This Hermes plugin delivers matched
30
+ Watchline events into a local Hermes gateway without exposing your laptop to
31
+ the internet.
32
+
33
+ ![Watchline Hermes delivery flow](https://watch.qordinate.ai/images/docs/watchline-hermes.png)
34
+
35
+ Watchline uses two integration planes:
36
+
37
+ - **Hosted MCP** gives Hermes the watch tools: `start_watch`,
38
+ `continue_watch`, `list_watches`, `pause_watch`, `resume_watch`, and
39
+ `delete_watch`.
40
+ - **This plugin** registers a Hermes gateway platform named `watchline`. It
41
+ polls a Watchline pull channel and forwards matched events as inbound Hermes
42
+ messages.
43
+
44
+ The plugin intentionally does not duplicate Watchline tools. Hermes discovers
45
+ tools from the hosted MCP server, and this adapter only handles local delivery.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ hermes plugins install qordinate-ai/watchline-hermes-plugin --enable
51
+ ```
52
+
53
+ After the first PyPI release, the package can also be installed directly:
54
+
55
+ ```bash
56
+ python -m pip install watchline-hermes-plugin
57
+ ```
58
+
59
+ For local development:
60
+
61
+ ```bash
62
+ hermes plugins install file:///absolute/path/to/watchline-hermes-plugin --enable
63
+ ```
64
+
65
+ ## Configure
66
+
67
+ Create a Watchline API key and pull channel at <https://watch.qordinate.ai>,
68
+ then run:
69
+
70
+ ```bash
71
+ hermes watchline configure \
72
+ --api-key wl_... \
73
+ --channel-id ch_... \
74
+ --user-id me \
75
+ --delivery-channel main \
76
+ --api-base-url https://api.watch.qordinate.ai
77
+ ```
78
+
79
+ The command writes:
80
+
81
+ - `gateway.platforms.watchline` for delivery.
82
+ - `mcp_servers.watchline` for hosted watch tools.
83
+
84
+ Restart the Hermes gateway after changing plugin or MCP config:
85
+
86
+ ```bash
87
+ hermes gateway restart
88
+ ```
89
+
90
+ ## Use
91
+
92
+ Ask Hermes to use Watchline:
93
+
94
+ ```text
95
+ Use the Watchline start_watch tool to watch Gmail for emails from my boss.
96
+ Ask me for the sender email address if needed.
97
+ ```
98
+
99
+ When a matching event arrives, Watchline queues it on your pull channel. The
100
+ plugin polls that channel, runs Hermes against your main/home channel, and
101
+ acknowledges the delivery only after Hermes accepts it.
102
+
103
+ ## Commands
104
+
105
+ ```bash
106
+ hermes watchline status
107
+ hermes watchline install-mcp
108
+ hermes watchline preview-delivery
109
+ ```
110
+
111
+ ## Configuration Reference
112
+
113
+ | Field | Required | Description |
114
+ | ----------------------- | -------- | ------------------------------------------------ |
115
+ | `api_key` | Yes | Watchline project API key. |
116
+ | `channel_id` | Yes | Watchline pull channel ID. |
117
+ | `user_id` | No | Stable Watchline user id. Defaults to `me`. |
118
+ | `api_base_url` | No | Watchline API base URL. Defaults to production. |
119
+ | `poll_interval_seconds` | No | Delivery polling interval. Minimum is 5 seconds. |
120
+ | `delivery_channel` | No | Hermes delivery target. Defaults to `main`, the first configured home channel. Use `telegram`, `discord`, or `telegram:<chat_id>[:thread_id]` to override. |
121
+
122
+ ## Links
123
+
124
+ - Dashboard: <https://watch.qordinate.ai>
125
+ - API: <https://api.watch.qordinate.ai>
126
+ - Source: <https://github.com/qordinate-ai/watchline-hermes-plugin>
@@ -0,0 +1,100 @@
1
+ # Watchline for Hermes Agent
2
+
3
+ Watchline is an event layer for agents. This Hermes plugin delivers matched
4
+ Watchline events into a local Hermes gateway without exposing your laptop to
5
+ the internet.
6
+
7
+ ![Watchline Hermes delivery flow](https://watch.qordinate.ai/images/docs/watchline-hermes.png)
8
+
9
+ Watchline uses two integration planes:
10
+
11
+ - **Hosted MCP** gives Hermes the watch tools: `start_watch`,
12
+ `continue_watch`, `list_watches`, `pause_watch`, `resume_watch`, and
13
+ `delete_watch`.
14
+ - **This plugin** registers a Hermes gateway platform named `watchline`. It
15
+ polls a Watchline pull channel and forwards matched events as inbound Hermes
16
+ messages.
17
+
18
+ The plugin intentionally does not duplicate Watchline tools. Hermes discovers
19
+ tools from the hosted MCP server, and this adapter only handles local delivery.
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ hermes plugins install qordinate-ai/watchline-hermes-plugin --enable
25
+ ```
26
+
27
+ After the first PyPI release, the package can also be installed directly:
28
+
29
+ ```bash
30
+ python -m pip install watchline-hermes-plugin
31
+ ```
32
+
33
+ For local development:
34
+
35
+ ```bash
36
+ hermes plugins install file:///absolute/path/to/watchline-hermes-plugin --enable
37
+ ```
38
+
39
+ ## Configure
40
+
41
+ Create a Watchline API key and pull channel at <https://watch.qordinate.ai>,
42
+ then run:
43
+
44
+ ```bash
45
+ hermes watchline configure \
46
+ --api-key wl_... \
47
+ --channel-id ch_... \
48
+ --user-id me \
49
+ --delivery-channel main \
50
+ --api-base-url https://api.watch.qordinate.ai
51
+ ```
52
+
53
+ The command writes:
54
+
55
+ - `gateway.platforms.watchline` for delivery.
56
+ - `mcp_servers.watchline` for hosted watch tools.
57
+
58
+ Restart the Hermes gateway after changing plugin or MCP config:
59
+
60
+ ```bash
61
+ hermes gateway restart
62
+ ```
63
+
64
+ ## Use
65
+
66
+ Ask Hermes to use Watchline:
67
+
68
+ ```text
69
+ Use the Watchline start_watch tool to watch Gmail for emails from my boss.
70
+ Ask me for the sender email address if needed.
71
+ ```
72
+
73
+ When a matching event arrives, Watchline queues it on your pull channel. The
74
+ plugin polls that channel, runs Hermes against your main/home channel, and
75
+ acknowledges the delivery only after Hermes accepts it.
76
+
77
+ ## Commands
78
+
79
+ ```bash
80
+ hermes watchline status
81
+ hermes watchline install-mcp
82
+ hermes watchline preview-delivery
83
+ ```
84
+
85
+ ## Configuration Reference
86
+
87
+ | Field | Required | Description |
88
+ | ----------------------- | -------- | ------------------------------------------------ |
89
+ | `api_key` | Yes | Watchline project API key. |
90
+ | `channel_id` | Yes | Watchline pull channel ID. |
91
+ | `user_id` | No | Stable Watchline user id. Defaults to `me`. |
92
+ | `api_base_url` | No | Watchline API base URL. Defaults to production. |
93
+ | `poll_interval_seconds` | No | Delivery polling interval. Minimum is 5 seconds. |
94
+ | `delivery_channel` | No | Hermes delivery target. Defaults to `main`, the first configured home channel. Use `telegram`, `discord`, or `telegram:<chat_id>[:thread_id]` to override. |
95
+
96
+ ## Links
97
+
98
+ - Dashboard: <https://watch.qordinate.ai>
99
+ - API: <https://api.watch.qordinate.ai>
100
+ - Source: <https://github.com/qordinate-ai/watchline-hermes-plugin>
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "watchline-hermes-plugin"
3
+ version = "0.1.0"
4
+ description = "Hermes Agent delivery adapter for Watchline."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Watchline", email = "support@qordinate.ai" }]
10
+ keywords = ["watchline", "hermes-agent", "agents", "mcp", "plugin", "pull-delivery"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: Communications",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ ]
22
+ dependencies = ["PyYAML>=6"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://watch.qordinate.ai"
26
+ Documentation = "https://watch.qordinate.ai/docs/hermes"
27
+ Repository = "https://github.com/qordinate-ai/watchline-hermes-plugin"
28
+ Issues = "https://github.com/qordinate-ai/watchline-hermes-plugin/issues"
29
+
30
+ [project.entry-points."hermes_agent.plugins"]
31
+ watchline = "watchline_hermes_plugin.adapter"
32
+
33
+ [build-system]
34
+ requires = ["setuptools>=77"]
35
+ build-backend = "setuptools.build_meta"
36
+
37
+ [tool.setuptools]
38
+ packages = ["watchline_hermes_plugin"]
39
+
40
+ [tool.ruff]
41
+ line-length = 100
42
+
43
+ [tool.ruff.lint]
44
+ select = ["E", "F", "I", "UP", "B", "SIM"]
45
+
46
+ [tool.pytest.ini_options]
47
+ addopts = ["--import-mode=importlib"]
48
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ import tempfile
2
+ from pathlib import Path
3
+
4
+ from watchline_hermes_plugin.config import (
5
+ load_hermes_config,
6
+ patch_mcp_config,
7
+ patch_watchline_config,
8
+ )
9
+
10
+
11
+ def test_configure_writes_platform_and_mcp_config():
12
+ with tempfile.TemporaryDirectory() as tmp:
13
+ path = Path(tmp) / "config.yaml"
14
+ config = patch_watchline_config(
15
+ api_key="wl_test",
16
+ channel_id="ch_test",
17
+ user_id="me",
18
+ path=path,
19
+ )
20
+ patch_mcp_config(config, path=path)
21
+
22
+ data = load_hermes_config(path)
23
+ assert "watchline" in data["plugins"]["enabled"]
24
+ assert data["gateway"]["platforms"]["watchline"]["extra"]["channel_id"] == "ch_test"
25
+ assert data["mcp_servers"]["watchline"]["headers"]["x-watchline-channel-id"] == "ch_test"
26
+ assert data["gateway"]["platforms"]["watchline"]["extra"]["delivery_channel"] == "main"
@@ -0,0 +1,29 @@
1
+ from watchline_hermes_plugin.delivery import format_delivery, parse_delivery_channel
2
+
3
+
4
+ def test_match_delivery_text_contains_intent_and_event():
5
+ text = format_delivery(
6
+ {
7
+ "type": "watchline.match",
8
+ "intent": "emails from my boss",
9
+ "event": {"subject": "Launch plan"},
10
+ },
11
+ )
12
+
13
+ assert "Matched event with user intent: emails from my boss" in text
14
+ assert '"subject": "Launch plan"' in text
15
+
16
+
17
+ def test_parse_default_delivery_channel():
18
+ channel = parse_delivery_channel("main")
19
+
20
+ assert channel.is_main
21
+
22
+
23
+ def test_parse_explicit_delivery_channel():
24
+ channel = parse_delivery_channel("telegram:123:456")
25
+
26
+ assert not channel.is_main
27
+ assert channel.platform == "telegram"
28
+ assert channel.chat_id == "123"
29
+ assert channel.thread_id == "456"
@@ -0,0 +1 @@
1
+ """Watchline Hermes plugin package."""
@@ -0,0 +1,281 @@
1
+ """Hermes platform adapter for Watchline pull delivery."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import os
9
+ from datetime import datetime
10
+ from typing import Any
11
+
12
+ from gateway.config import Platform, PlatformConfig
13
+ from gateway.platforms.base import (
14
+ BasePlatformAdapter,
15
+ MessageEvent,
16
+ MessageType,
17
+ SendResult,
18
+ )
19
+ from gateway.session import SessionSource
20
+
21
+ from watchline_hermes_plugin.client import WatchlineClient
22
+ from watchline_hermes_plugin.config import (
23
+ DEFAULT_API_BASE_URL,
24
+ WATCHLINE_PLATFORM_NAME,
25
+ config_from_platform,
26
+ normalize_config,
27
+ )
28
+ from watchline_hermes_plugin.delivery import delivery_id, format_delivery, parse_delivery_channel
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class WatchlinePlatformAdapter(BasePlatformAdapter):
34
+ """Pull Watchline deliveries and feed them into Hermes' normal gateway."""
35
+
36
+ def __init__(self, config: PlatformConfig):
37
+ super().__init__(config=config, platform=Platform(WATCHLINE_PLATFORM_NAME))
38
+ self.watchline_config = config_from_platform(config)
39
+ self.client = WatchlineClient(self.watchline_config)
40
+ self._poll_task: asyncio.Task | None = None
41
+ self.gateway_runner: Any | None = None
42
+
43
+ @property
44
+ def name(self) -> str:
45
+ return "Watchline"
46
+
47
+ async def connect(self) -> bool:
48
+ self._running = True
49
+ self._poll_task = asyncio.create_task(self._poll_loop())
50
+ return True
51
+
52
+ async def disconnect(self) -> None:
53
+ self._running = False
54
+ if self._poll_task:
55
+ self._poll_task.cancel()
56
+ with contextlib.suppress(asyncio.CancelledError):
57
+ await self._poll_task
58
+
59
+ async def send(
60
+ self,
61
+ chat_id: str,
62
+ content: str,
63
+ reply_to: str | None = None,
64
+ metadata: dict[str, Any] | None = None,
65
+ ) -> SendResult:
66
+ return SendResult(
67
+ success=False,
68
+ error=(
69
+ "Watchline is an inbound delivery adapter and cannot send "
70
+ "messages back to Watchline."
71
+ ),
72
+ )
73
+
74
+ async def _poll_loop(self) -> None:
75
+ while self._running:
76
+ try:
77
+ delivered = await self._poll_once()
78
+ if delivered:
79
+ logger.info("Watchline delivered %s event(s) into Hermes", delivered)
80
+ except Exception as error:
81
+ logger.warning("Watchline delivery poll failed: %s", error)
82
+ await asyncio.sleep(self.watchline_config.poll_interval_seconds)
83
+
84
+ async def _poll_once(self) -> int:
85
+ cursor: str | None = None
86
+ ack_ids: list[str] = []
87
+ delivered = 0
88
+ while True:
89
+ response = await asyncio.to_thread(self.client.pending, limit=50, cursor=cursor)
90
+ for item in response.get("data", []):
91
+ if isinstance(item, dict):
92
+ await self._deliver(item)
93
+ delivery_public_id = delivery_id(item)
94
+ if delivery_public_id:
95
+ ack_ids.append(delivery_public_id)
96
+ delivered += 1
97
+ next_cursor = response.get("next_cursor")
98
+ cursor = next_cursor if isinstance(next_cursor, str) and next_cursor else None
99
+ if not cursor:
100
+ break
101
+ await asyncio.to_thread(self.client.ack, ack_ids)
102
+ return delivered
103
+
104
+ async def _deliver(self, delivery: dict[str, Any]) -> None:
105
+ if not self.gateway_runner:
106
+ raise RuntimeError("Hermes gateway runner is not ready.")
107
+ if not self._message_handler:
108
+ raise RuntimeError("Hermes gateway message handler is not ready.")
109
+ source = self._resolve_delivery_source(delivery)
110
+ message_id = delivery_id(delivery) or None
111
+ event = MessageEvent(
112
+ text=format_delivery(delivery),
113
+ message_type=MessageType.TEXT,
114
+ source=source,
115
+ raw_message=delivery,
116
+ message_id=message_id,
117
+ timestamp=datetime.now(),
118
+ internal=True,
119
+ )
120
+ response_text = await self.gateway_runner._handle_message(event)
121
+ if not response_text:
122
+ return
123
+
124
+ adapter = self.gateway_runner.adapters.get(source.platform)
125
+ if not adapter:
126
+ raise RuntimeError(f"Hermes adapter for {source.platform.value} is not active.")
127
+ metadata = {"thread_id": source.thread_id} if source.thread_id else None
128
+ result = await adapter.send(
129
+ chat_id=source.chat_id,
130
+ content=response_text,
131
+ metadata=metadata,
132
+ )
133
+ if not getattr(result, "success", False):
134
+ raise RuntimeError(getattr(result, "error", "Hermes send returned success=False"))
135
+
136
+ def _resolve_delivery_source(self, delivery: dict[str, Any]) -> SessionSource:
137
+ channel = parse_delivery_channel(self.watchline_config.delivery_channel)
138
+ if channel.is_main:
139
+ platform, chat_id, chat_name, thread_id = self._main_home_channel()
140
+ else:
141
+ platform = Platform(channel.platform or "")
142
+ if channel.chat_id:
143
+ chat_id = channel.chat_id
144
+ chat_name = channel.chat_id
145
+ thread_id = channel.thread_id
146
+ else:
147
+ home = self.gateway_runner.config.get_home_channel(platform)
148
+ if not home or not home.chat_id:
149
+ raise RuntimeError(
150
+ f"No Hermes home channel configured for {platform.value}; "
151
+ "run /sethome there or set watchline.delivery_channel to "
152
+ f"{platform.value}:<chat_id>."
153
+ )
154
+ chat_id = str(home.chat_id)
155
+ chat_name = home.name
156
+ thread_id = str(home.thread_id) if home.thread_id else None
157
+
158
+ message_id = delivery_id(delivery) or None
159
+ return SessionSource(
160
+ platform=platform,
161
+ chat_id=chat_id,
162
+ chat_name=chat_name,
163
+ chat_type="dm",
164
+ user_id="system:watchline",
165
+ user_name="Watchline",
166
+ thread_id=thread_id,
167
+ message_id=message_id,
168
+ )
169
+
170
+ def _main_home_channel(self) -> tuple[Platform, str, str, str | None]:
171
+ excluded = {
172
+ self.platform,
173
+ Platform.LOCAL,
174
+ Platform.API_SERVER,
175
+ Platform.WEBHOOK,
176
+ Platform.MSGRAPH_WEBHOOK,
177
+ }
178
+ for platform in self.gateway_runner.adapters:
179
+ if platform in excluded:
180
+ continue
181
+ home = self.gateway_runner.config.get_home_channel(platform)
182
+ if home and home.chat_id:
183
+ return (
184
+ platform,
185
+ str(home.chat_id),
186
+ home.name,
187
+ str(home.thread_id) if home.thread_id else None,
188
+ )
189
+ raise RuntimeError(
190
+ "No Hermes main/home channel configured for Watchline delivery. "
191
+ "Run /sethome in the destination chat or set watchline.delivery_channel "
192
+ "to <platform>:<chat_id>."
193
+ )
194
+
195
+
196
+ def check_requirements() -> bool:
197
+ return True
198
+
199
+
200
+ def validate_config(config: Any) -> bool:
201
+ try:
202
+ config_from_platform(config)
203
+ return True
204
+ except Exception:
205
+ return False
206
+
207
+
208
+ def env_enablement() -> dict[str, Any] | None:
209
+ api_key = os.getenv("WATCHLINE_API_KEY", "").strip()
210
+ channel_id = os.getenv("WATCHLINE_CHANNEL_ID", "").strip()
211
+ if not api_key or not channel_id:
212
+ return None
213
+ return {
214
+ "api_key": api_key,
215
+ "channel_id": channel_id,
216
+ "user_id": os.getenv("WATCHLINE_USER_ID", "me").strip() or "me",
217
+ "api_base_url": os.getenv("WATCHLINE_API_BASE_URL", DEFAULT_API_BASE_URL).strip()
218
+ or DEFAULT_API_BASE_URL,
219
+ "delivery_channel": os.getenv("WATCHLINE_DELIVERY_CHANNEL", "main").strip() or "main",
220
+ }
221
+
222
+
223
+ def _apply_yaml_config(
224
+ yaml_cfg: dict[str, Any],
225
+ platform_cfg: dict[str, Any],
226
+ ) -> dict[str, Any] | None:
227
+ gateway_platforms = (
228
+ yaml_cfg.get("gateway", {}).get("platforms", {})
229
+ if isinstance(yaml_cfg.get("gateway"), dict)
230
+ else {}
231
+ )
232
+ raw = (
233
+ gateway_platforms.get(WATCHLINE_PLATFORM_NAME, {})
234
+ if isinstance(gateway_platforms, dict)
235
+ else {}
236
+ )
237
+ extra = raw.get("extra", {}) if isinstance(raw, dict) else {}
238
+ try:
239
+ normalized = normalize_config(extra)
240
+ except Exception:
241
+ return None
242
+ return {
243
+ "api_key": normalized.api_key,
244
+ "channel_id": normalized.channel_id,
245
+ "user_id": normalized.user_id,
246
+ "api_base_url": normalized.api_base_url,
247
+ "poll_interval_seconds": normalized.poll_interval_seconds,
248
+ "delivery_channel": normalized.delivery_channel,
249
+ }
250
+
251
+
252
+ def register(ctx: Any) -> None:
253
+ ctx.register_platform(
254
+ name=WATCHLINE_PLATFORM_NAME,
255
+ label="Watchline",
256
+ adapter_factory=lambda cfg: WatchlinePlatformAdapter(cfg),
257
+ check_fn=check_requirements,
258
+ validate_config=validate_config,
259
+ is_connected=validate_config,
260
+ env_enablement_fn=env_enablement,
261
+ apply_yaml_config_fn=_apply_yaml_config,
262
+ allow_all_env="WATCHLINE_ALLOW_ALL_USERS",
263
+ allowed_users_env="WATCHLINE_ALLOWED_USERS",
264
+ platform_hint=(
265
+ "Watchline delivers matched external events. Treat each message as "
266
+ "fresh event context and avoid saying it came from a human chat."
267
+ ),
268
+ emoji="🔔",
269
+ )
270
+ _register_cli(ctx)
271
+
272
+
273
+ def _register_cli(ctx: Any) -> None:
274
+ from watchline_hermes_plugin.watchline_cli import build_cli
275
+
276
+ ctx.register_cli_command(
277
+ name=WATCHLINE_PLATFORM_NAME,
278
+ help="Configure Watchline delivery and hosted MCP for Hermes.",
279
+ setup_fn=build_cli,
280
+ handler_fn=None,
281
+ )
@@ -0,0 +1,57 @@
1
+ """Tiny Watchline API client used by the Hermes delivery adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+ from urllib.error import HTTPError
8
+ from urllib.request import Request, urlopen
9
+
10
+ from watchline_hermes_plugin.config import WatchlineConfig
11
+
12
+
13
+ class WatchlineApiError(RuntimeError):
14
+ pass
15
+
16
+
17
+ class WatchlineClient:
18
+ def __init__(self, config: WatchlineConfig):
19
+ self.config = config
20
+
21
+ def pending(self, *, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
22
+ body: dict[str, Any] = {"channel_id": self.config.channel_id, "limit": limit}
23
+ if cursor:
24
+ body["cursor"] = cursor
25
+ return self._post("/v1/deliveries.pending", body)
26
+
27
+ def ack(self, delivery_ids: list[str]) -> None:
28
+ if not delivery_ids:
29
+ return
30
+ self._post(
31
+ "/v1/deliveries.ack",
32
+ {"channel_id": self.config.channel_id, "delivery_ids": delivery_ids},
33
+ )
34
+
35
+ def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
36
+ raw_body = json.dumps(body).encode("utf-8")
37
+ request = Request(
38
+ f"{self.config.api_base_url.rstrip('/')}{path}",
39
+ data=raw_body,
40
+ method="POST",
41
+ headers={
42
+ "Authorization": f"Bearer {self.config.api_key}",
43
+ "Content-Type": "application/json",
44
+ "Accept": "application/json",
45
+ "User-Agent": "watchline-hermes-plugin/0.1.0",
46
+ },
47
+ )
48
+ try:
49
+ with urlopen(request, timeout=30) as response:
50
+ raw = response.read()
51
+ except HTTPError as error:
52
+ detail = error.read().decode("utf-8", errors="replace")
53
+ raise WatchlineApiError(f"Watchline API returned {error.code}: {detail}") from error
54
+ if not raw:
55
+ return {}
56
+ parsed = json.loads(raw.decode("utf-8"))
57
+ return parsed if isinstance(parsed, dict) else {}
@@ -0,0 +1,215 @@
1
+ """Configuration helpers for the Watchline Hermes plugin.
2
+
3
+ Hermes platform adapters receive a generic ``PlatformConfig`` object. Keeping
4
+ Watchline-specific validation in this module lets the CLI commands and gateway
5
+ adapter share the same rules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from os import environ
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ try:
16
+ import yaml
17
+ except ImportError: # pragma: no cover - Hermes normally ships PyYAML.
18
+ yaml = None
19
+
20
+ DEFAULT_API_BASE_URL = "https://api.watch.qordinate.ai"
21
+ DEFAULT_USER_ID = "me"
22
+ DEFAULT_POLL_INTERVAL_SECONDS = 15
23
+ MIN_POLL_INTERVAL_SECONDS = 5
24
+ HERMES_CONFIG_PATH = Path.home() / ".hermes" / "config.yaml"
25
+ WATCHLINE_PLATFORM_NAME = "watchline"
26
+ DEFAULT_DELIVERY_CHANNEL = "main"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class WatchlineConfig:
31
+ api_key: str
32
+ channel_id: str
33
+ user_id: str = DEFAULT_USER_ID
34
+ api_base_url: str = DEFAULT_API_BASE_URL
35
+ poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS
36
+ delivery_channel: str = DEFAULT_DELIVERY_CHANNEL
37
+
38
+ @property
39
+ def mcp_url(self) -> str:
40
+ return f"{self.api_base_url.rstrip('/')}/v1/mcp"
41
+
42
+
43
+ def normalize_config(value: Any) -> WatchlineConfig:
44
+ """Normalize config from Hermes ``PlatformConfig.extra`` or raw YAML."""
45
+
46
+ raw = value if isinstance(value, dict) else {}
47
+ api_key = _read_string(raw, "api_key") or environ.get("WATCHLINE_API_KEY", "").strip()
48
+ channel_id = _read_string(raw, "channel_id") or environ.get("WATCHLINE_CHANNEL_ID", "").strip()
49
+ user_id = (
50
+ _read_string(raw, "user_id")
51
+ or environ.get("WATCHLINE_USER_ID", "").strip()
52
+ or DEFAULT_USER_ID
53
+ )
54
+ api_base_url = (
55
+ _read_string(raw, "api_base_url")
56
+ or environ.get("WATCHLINE_API_BASE_URL", "").strip()
57
+ or DEFAULT_API_BASE_URL
58
+ )
59
+ poll_interval_seconds = _read_int(raw, "poll_interval_seconds")
60
+ if poll_interval_seconds is None:
61
+ poll_interval_seconds = DEFAULT_POLL_INTERVAL_SECONDS
62
+ delivery_channel = (
63
+ _read_string(raw, "delivery_channel")
64
+ or environ.get("WATCHLINE_DELIVERY_CHANNEL", "").strip()
65
+ or DEFAULT_DELIVERY_CHANNEL
66
+ )
67
+
68
+ if not api_key:
69
+ raise ValueError("WATCHLINE_API_KEY or watchline.api_key is required.")
70
+ if not channel_id:
71
+ raise ValueError("WATCHLINE_CHANNEL_ID or watchline.channel_id is required.")
72
+
73
+ return WatchlineConfig(
74
+ api_key=api_key,
75
+ channel_id=channel_id,
76
+ user_id=user_id,
77
+ api_base_url=api_base_url.rstrip("/"),
78
+ poll_interval_seconds=max(MIN_POLL_INTERVAL_SECONDS, poll_interval_seconds),
79
+ delivery_channel=delivery_channel,
80
+ )
81
+
82
+
83
+ def config_from_platform(platform_config: Any) -> WatchlineConfig:
84
+ return normalize_config(getattr(platform_config, "extra", {}) or {})
85
+
86
+
87
+ def load_hermes_config(path: Path = HERMES_CONFIG_PATH) -> dict[str, Any]:
88
+ if not path.exists():
89
+ return {}
90
+ raw = path.read_text(encoding="utf-8")
91
+ if yaml is None:
92
+ import json
93
+
94
+ loaded = json.loads(raw) if raw.strip() else {}
95
+ else:
96
+ loaded = yaml.safe_load(raw)
97
+ return loaded if isinstance(loaded, dict) else {}
98
+
99
+
100
+ def write_hermes_config(data: dict[str, Any], path: Path = HERMES_CONFIG_PATH) -> None:
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ if path.exists():
103
+ backup = path.with_suffix(f"{path.suffix}.watchline.bak")
104
+ backup.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
105
+ backup.chmod(0o600)
106
+ if yaml is None:
107
+ import json
108
+
109
+ rendered = f"{json.dumps(data, indent=2)}\n"
110
+ else:
111
+ rendered = yaml.safe_dump(data, sort_keys=False)
112
+ path.write_text(rendered, encoding="utf-8")
113
+ path.chmod(0o600)
114
+
115
+
116
+ def patch_watchline_config(
117
+ *,
118
+ api_key: str,
119
+ channel_id: str,
120
+ user_id: str = DEFAULT_USER_ID,
121
+ api_base_url: str = DEFAULT_API_BASE_URL,
122
+ poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS,
123
+ delivery_channel: str = DEFAULT_DELIVERY_CHANNEL,
124
+ path: Path = HERMES_CONFIG_PATH,
125
+ ) -> WatchlineConfig:
126
+ """Write both Hermes platform config and top-level Watchline config."""
127
+
128
+ config = normalize_config(
129
+ {
130
+ "api_key": api_key,
131
+ "channel_id": channel_id,
132
+ "user_id": user_id,
133
+ "api_base_url": api_base_url,
134
+ "poll_interval_seconds": poll_interval_seconds,
135
+ "delivery_channel": delivery_channel,
136
+ },
137
+ )
138
+ data = load_hermes_config(path)
139
+ plugins = _ensure_dict(data, "plugins")
140
+ enabled = plugins.setdefault("enabled", [])
141
+ if not isinstance(enabled, list):
142
+ enabled = []
143
+ plugins["enabled"] = enabled
144
+ if WATCHLINE_PLATFORM_NAME not in enabled:
145
+ enabled.append(WATCHLINE_PLATFORM_NAME)
146
+
147
+ gateway = _ensure_dict(data, "gateway")
148
+ platforms = _ensure_dict(gateway, "platforms")
149
+ platforms[WATCHLINE_PLATFORM_NAME] = {
150
+ "enabled": True,
151
+ "extra": {
152
+ "api_key": config.api_key,
153
+ "channel_id": config.channel_id,
154
+ "user_id": config.user_id,
155
+ "api_base_url": config.api_base_url,
156
+ "poll_interval_seconds": config.poll_interval_seconds,
157
+ "delivery_channel": config.delivery_channel,
158
+ },
159
+ }
160
+ write_hermes_config(data, path)
161
+ return config
162
+
163
+
164
+ def patch_mcp_config(config: WatchlineConfig, path: Path = HERMES_CONFIG_PATH) -> None:
165
+ """Install the hosted Watchline MCP server into Hermes config.yaml."""
166
+
167
+ data = load_hermes_config(path)
168
+ mcp_servers = _ensure_dict(data, "mcp_servers")
169
+ mcp_servers[WATCHLINE_PLATFORM_NAME] = {
170
+ "url": config.mcp_url,
171
+ "headers": {
172
+ "Authorization": f"Bearer {config.api_key}",
173
+ "x-watchline-channel-id": config.channel_id,
174
+ "x-watchline-user-id": config.user_id,
175
+ },
176
+ }
177
+ write_hermes_config(data, path)
178
+
179
+
180
+ def mcp_config_for_display(config: WatchlineConfig) -> dict[str, Any]:
181
+ return {
182
+ "url": config.mcp_url,
183
+ "headers": {
184
+ "Authorization": "Bearer wl_...",
185
+ "x-watchline-channel-id": config.channel_id,
186
+ "x-watchline-user-id": config.user_id,
187
+ },
188
+ }
189
+
190
+
191
+ def _ensure_dict(parent: dict[str, Any], key: str) -> dict[str, Any]:
192
+ value = parent.setdefault(key, {})
193
+ if not isinstance(value, dict):
194
+ value = {}
195
+ parent[key] = value
196
+ return value
197
+
198
+
199
+ def _read_string(raw: dict[str, Any], key: str) -> str | None:
200
+ value = raw.get(key)
201
+ if isinstance(value, str) and value.strip():
202
+ return value.strip()
203
+ return None
204
+
205
+
206
+ def _read_int(raw: dict[str, Any], key: str) -> int | None:
207
+ value = raw.get(key)
208
+ if isinstance(value, int):
209
+ return value
210
+ if isinstance(value, str) and value.strip():
211
+ try:
212
+ return int(value)
213
+ except ValueError:
214
+ return None
215
+ return None
@@ -0,0 +1,82 @@
1
+ """Delivery formatting and polling primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Protocol
7
+
8
+
9
+ class DeliveryClient(Protocol):
10
+ def pending(self, *, limit: int = 50, cursor: str | None = None) -> dict[str, Any]: ...
11
+
12
+ def ack(self, delivery_ids: list[str]) -> None: ...
13
+
14
+
15
+ def format_delivery(delivery: dict[str, Any]) -> str:
16
+ """Render a Watchline delivery as the user message Hermes should receive."""
17
+
18
+ if delivery.get("type") == "watchline.match":
19
+ return "\n".join(
20
+ [
21
+ f"Matched event with user intent: {delivery.get('intent', '')}",
22
+ "",
23
+ "Use this event as fresh context. Only act if the user intent asks for action.",
24
+ "",
25
+ "Event:",
26
+ _stable_json(delivery.get("event", {})),
27
+ ],
28
+ )
29
+ return "\n".join(
30
+ [
31
+ f"Watch needs action: {delivery.get('action', '')}",
32
+ "",
33
+ f"User intent: {delivery.get('intent', '')}",
34
+ "",
35
+ "Connection links:",
36
+ _stable_json(delivery.get("connect_urls", {})),
37
+ ],
38
+ )
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class DeliveryChannel:
43
+ value: str
44
+ platform: str | None = None
45
+ chat_id: str | None = None
46
+ thread_id: str | None = None
47
+
48
+ @property
49
+ def is_main(self) -> bool:
50
+ return self.value == "main"
51
+
52
+ @property
53
+ def uses_home_channel(self) -> bool:
54
+ return self.platform is not None and self.chat_id is None
55
+
56
+
57
+ def parse_delivery_channel(value: str | None) -> DeliveryChannel:
58
+ raw = (value or "main").strip()
59
+ if not raw or raw.lower() == "main":
60
+ return DeliveryChannel(value="main")
61
+
62
+ parts = raw.split(":", 2)
63
+ platform = parts[0].strip().lower()
64
+ chat_id = parts[1].strip() if len(parts) > 1 and parts[1].strip() else None
65
+ thread_id = parts[2].strip() if len(parts) > 2 and parts[2].strip() else None
66
+ return DeliveryChannel(
67
+ value=raw,
68
+ platform=platform,
69
+ chat_id=chat_id,
70
+ thread_id=thread_id,
71
+ )
72
+
73
+
74
+ def delivery_id(delivery: dict[str, Any]) -> str:
75
+ raw = delivery.get("delivery_id")
76
+ return str(raw) if raw else ""
77
+
78
+
79
+ def _stable_json(value: Any) -> str:
80
+ import json
81
+
82
+ return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False)
@@ -0,0 +1,116 @@
1
+ """Hermes CLI commands for Watchline setup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from typing import Any
8
+
9
+ from watchline_hermes_plugin.config import (
10
+ DEFAULT_API_BASE_URL,
11
+ DEFAULT_DELIVERY_CHANNEL,
12
+ DEFAULT_POLL_INTERVAL_SECONDS,
13
+ DEFAULT_USER_ID,
14
+ HERMES_CONFIG_PATH,
15
+ load_hermes_config,
16
+ mcp_config_for_display,
17
+ normalize_config,
18
+ patch_mcp_config,
19
+ patch_watchline_config,
20
+ )
21
+
22
+
23
+ def build_cli(parser: argparse.ArgumentParser) -> None:
24
+ subcommands = parser.add_subparsers(dest="watchline_command", required=True)
25
+
26
+ configure = subcommands.add_parser("configure", help="Save Watchline Hermes config")
27
+ configure.add_argument("--api-key", required=True)
28
+ configure.add_argument("--channel-id", required=True)
29
+ configure.add_argument("--user-id", default=DEFAULT_USER_ID)
30
+ configure.add_argument("--api-base-url", default=DEFAULT_API_BASE_URL)
31
+ configure.add_argument(
32
+ "--poll-interval-seconds",
33
+ type=int,
34
+ default=DEFAULT_POLL_INTERVAL_SECONDS,
35
+ )
36
+ configure.add_argument("--delivery-channel", default=DEFAULT_DELIVERY_CHANNEL)
37
+ configure.set_defaults(func=_configure)
38
+
39
+ install_mcp = subcommands.add_parser("install-mcp", help="Install Watchline hosted MCP config")
40
+ install_mcp.set_defaults(func=_install_mcp)
41
+
42
+ status = subcommands.add_parser("status", help="Print Watchline config status")
43
+ status.set_defaults(func=_status)
44
+
45
+ preview = subcommands.add_parser(
46
+ "preview-delivery",
47
+ help="Preview a Watchline delivery message",
48
+ )
49
+ preview.set_defaults(func=_preview_delivery)
50
+
51
+
52
+ def _configure(args: argparse.Namespace) -> None:
53
+ config = patch_watchline_config(
54
+ api_key=args.api_key,
55
+ channel_id=args.channel_id,
56
+ user_id=args.user_id,
57
+ api_base_url=args.api_base_url,
58
+ poll_interval_seconds=args.poll_interval_seconds,
59
+ delivery_channel=args.delivery_channel,
60
+ )
61
+ patch_mcp_config(config)
62
+ print(f"Saved Watchline platform and MCP config to {HERMES_CONFIG_PATH}.")
63
+ print("Restart Hermes gateway to load the adapter and MCP tools.")
64
+
65
+
66
+ def _install_mcp(_: argparse.Namespace) -> None:
67
+ config = _read_saved_config()
68
+ patch_mcp_config(config)
69
+ print(f"Saved Watchline MCP server to {HERMES_CONFIG_PATH}.")
70
+ print("Restart Hermes gateway or start a new Hermes session to load MCP tools.")
71
+
72
+
73
+ def _status(_: argparse.Namespace) -> None:
74
+ config = _read_saved_config()
75
+ display = mcp_config_for_display(config)
76
+ print("Watchline is configured.")
77
+ print(f" channel_id: {config.channel_id}")
78
+ print(f" user_id: {config.user_id}")
79
+ print(f" api_base: {config.api_base_url}")
80
+ print(f" delivery: {config.delivery_channel}")
81
+ print("")
82
+ print("Hermes MCP entry:")
83
+ print(json.dumps(display, indent=2))
84
+
85
+
86
+ def _preview_delivery(_: argparse.Namespace) -> None:
87
+ from watchline_hermes_plugin.delivery import format_delivery
88
+
89
+ print(
90
+ format_delivery(
91
+ {
92
+ "type": "watchline.match",
93
+ "delivery_id": "del_preview",
94
+ "watch_id": "watch_preview",
95
+ "user_id": "me",
96
+ "intent": "urgent billing emails",
97
+ "event": {
98
+ "from": "customer@example.com",
99
+ "subject": "Production invoice failure",
100
+ "snippet": "Our invoice payment is failing before renewal.",
101
+ },
102
+ },
103
+ ),
104
+ )
105
+
106
+
107
+ def _read_saved_config() -> Any:
108
+ data = load_hermes_config()
109
+ try:
110
+ platform = data["gateway"]["platforms"]["watchline"]
111
+ return normalize_config(platform.get("extra", {}))
112
+ except Exception as error:
113
+ raise SystemExit(
114
+ "Watchline is not configured. Run `hermes watchline configure` "
115
+ f"first. ({error})"
116
+ ) from error
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: watchline-hermes-plugin
3
+ Version: 0.1.0
4
+ Summary: Hermes Agent delivery adapter for Watchline.
5
+ Author-email: Watchline <support@qordinate.ai>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://watch.qordinate.ai
8
+ Project-URL: Documentation, https://watch.qordinate.ai/docs/hermes
9
+ Project-URL: Repository, https://github.com/qordinate-ai/watchline-hermes-plugin
10
+ Project-URL: Issues, https://github.com/qordinate-ai/watchline-hermes-plugin/issues
11
+ Keywords: watchline,hermes-agent,agents,mcp,plugin,pull-delivery
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Communications
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: PyYAML>=6
25
+ Dynamic: license-file
26
+
27
+ # Watchline for Hermes Agent
28
+
29
+ Watchline is an event layer for agents. This Hermes plugin delivers matched
30
+ Watchline events into a local Hermes gateway without exposing your laptop to
31
+ the internet.
32
+
33
+ ![Watchline Hermes delivery flow](https://watch.qordinate.ai/images/docs/watchline-hermes.png)
34
+
35
+ Watchline uses two integration planes:
36
+
37
+ - **Hosted MCP** gives Hermes the watch tools: `start_watch`,
38
+ `continue_watch`, `list_watches`, `pause_watch`, `resume_watch`, and
39
+ `delete_watch`.
40
+ - **This plugin** registers a Hermes gateway platform named `watchline`. It
41
+ polls a Watchline pull channel and forwards matched events as inbound Hermes
42
+ messages.
43
+
44
+ The plugin intentionally does not duplicate Watchline tools. Hermes discovers
45
+ tools from the hosted MCP server, and this adapter only handles local delivery.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ hermes plugins install qordinate-ai/watchline-hermes-plugin --enable
51
+ ```
52
+
53
+ After the first PyPI release, the package can also be installed directly:
54
+
55
+ ```bash
56
+ python -m pip install watchline-hermes-plugin
57
+ ```
58
+
59
+ For local development:
60
+
61
+ ```bash
62
+ hermes plugins install file:///absolute/path/to/watchline-hermes-plugin --enable
63
+ ```
64
+
65
+ ## Configure
66
+
67
+ Create a Watchline API key and pull channel at <https://watch.qordinate.ai>,
68
+ then run:
69
+
70
+ ```bash
71
+ hermes watchline configure \
72
+ --api-key wl_... \
73
+ --channel-id ch_... \
74
+ --user-id me \
75
+ --delivery-channel main \
76
+ --api-base-url https://api.watch.qordinate.ai
77
+ ```
78
+
79
+ The command writes:
80
+
81
+ - `gateway.platforms.watchline` for delivery.
82
+ - `mcp_servers.watchline` for hosted watch tools.
83
+
84
+ Restart the Hermes gateway after changing plugin or MCP config:
85
+
86
+ ```bash
87
+ hermes gateway restart
88
+ ```
89
+
90
+ ## Use
91
+
92
+ Ask Hermes to use Watchline:
93
+
94
+ ```text
95
+ Use the Watchline start_watch tool to watch Gmail for emails from my boss.
96
+ Ask me for the sender email address if needed.
97
+ ```
98
+
99
+ When a matching event arrives, Watchline queues it on your pull channel. The
100
+ plugin polls that channel, runs Hermes against your main/home channel, and
101
+ acknowledges the delivery only after Hermes accepts it.
102
+
103
+ ## Commands
104
+
105
+ ```bash
106
+ hermes watchline status
107
+ hermes watchline install-mcp
108
+ hermes watchline preview-delivery
109
+ ```
110
+
111
+ ## Configuration Reference
112
+
113
+ | Field | Required | Description |
114
+ | ----------------------- | -------- | ------------------------------------------------ |
115
+ | `api_key` | Yes | Watchline project API key. |
116
+ | `channel_id` | Yes | Watchline pull channel ID. |
117
+ | `user_id` | No | Stable Watchline user id. Defaults to `me`. |
118
+ | `api_base_url` | No | Watchline API base URL. Defaults to production. |
119
+ | `poll_interval_seconds` | No | Delivery polling interval. Minimum is 5 seconds. |
120
+ | `delivery_channel` | No | Hermes delivery target. Defaults to `main`, the first configured home channel. Use `telegram`, `discord`, or `telegram:<chat_id>[:thread_id]` to override. |
121
+
122
+ ## Links
123
+
124
+ - Dashboard: <https://watch.qordinate.ai>
125
+ - API: <https://api.watch.qordinate.ai>
126
+ - Source: <https://github.com/qordinate-ai/watchline-hermes-plugin>
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ tests/test_config.py
5
+ tests/test_delivery.py
6
+ watchline_hermes_plugin/__init__.py
7
+ watchline_hermes_plugin/adapter.py
8
+ watchline_hermes_plugin/client.py
9
+ watchline_hermes_plugin/config.py
10
+ watchline_hermes_plugin/delivery.py
11
+ watchline_hermes_plugin/watchline_cli.py
12
+ watchline_hermes_plugin.egg-info/PKG-INFO
13
+ watchline_hermes_plugin.egg-info/SOURCES.txt
14
+ watchline_hermes_plugin.egg-info/dependency_links.txt
15
+ watchline_hermes_plugin.egg-info/entry_points.txt
16
+ watchline_hermes_plugin.egg-info/requires.txt
17
+ watchline_hermes_plugin.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [hermes_agent.plugins]
2
+ watchline = watchline_hermes_plugin.adapter
@@ -0,0 +1 @@
1
+ watchline_hermes_plugin