"""nmbr platform adapter for Hermes Agent (plugin). Give your Hermes agent a number: it gets an ``800-xxx-xxx`` nmbr, and you talk to it from the nmbr app on your phone. Inbound messages arrive over the Agent API's long-poll endpoint (works from a laptop or Mac mini behind NAT — no public URL), replies and typing go out over the same API. Only httpx, which Hermes already depends on. Install:: ~/.hermes/plugins/nmbr/{plugin.yaml,adapter.py} (see install.sh) hermes gateway setup # or set NMBR_AGENT_TOKEN and NMBR_ALLOWED_USERS Configuration (config.yaml):: platforms: nmbr: enabled: true extra: token: "agent:…" # or NMBR_AGENT_TOKEN base_url: "https://nmbr.ai/api" home_channel: { chat_id: "123-456-789", name: "Ahmad" } Environment (env wins over config.yaml ``extra``):: NMBR_AGENT_TOKEN agent token (required) NMBR_BASE_URL API base (default https://nmbr.ai/api) NMBR_ALLOWED_USERS comma-separated nmbrs allowed to talk to the agent NMBR_ALLOW_ALL_USERS true = every contact who added the agent may talk NMBR_HOME_CHANNEL nmbr or conversation id for cron delivery NMBR_HOME_CHANNEL_NAME label for the home channel Identity model: nmbr authenticates every sender; ``user_id`` is the sender's nmbr, so ``NMBR_ALLOWED_USERS`` is a list of nmbrs. nmbr itself only lets people who ADDED the agent as a contact message it at all. Approvals (v0.2): a dangerous command becomes a native nmbr approval card (``POST /agent/v1/actions``, kind ``hermes.exec``). Tapping Approve/Reject on the phone resolves Hermes' waiting agent thread through ``tools.approval.resolve_gateway_approval`` — the same mechanism as the typed ``/approve`` / ``/deny``, which keep working. Edit the card's ``scope`` to ``session`` or ``always`` before approving to remember the pattern. If the card cannot be sent, the gateway falls back to its plain-text prompt. """ from __future__ import annotations import asyncio import logging import os import re import time from datetime import datetime, timezone from typing import Any, Dict, List, Optional try: import httpx HTTPX_AVAILABLE = True except ImportError: # pragma: no cover - Hermes always ships httpx HTTPX_AVAILABLE = False httpx = None # type: ignore[assignment] from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, MessageType, SendResult, ) try: from agent.secret_scope import UnscopedSecretError as _UnscopedSecretError from agent.secret_scope import get_secret as _scoped_get_secret except ImportError: # pragma: no cover - older Hermes _UnscopedSecretError = Exception # type: ignore[assignment,misc] def _scoped_get_secret(name, default=None): # type: ignore[no-redef] return os.getenv(name, default) try: # gateway approval registry — resolves the agent thread blocked on a dangerous command from tools.approval import resolve_gateway_approval as _resolve_gateway_approval except ImportError: # pragma: no cover - outside a Hermes runtime _resolve_gateway_approval = None # type: ignore[assignment] try: from tools.approval import _get_approval_timeout as _hermes_approval_timeout except ImportError: # pragma: no cover - older Hermes or outside the runtime _hermes_approval_timeout = None # type: ignore[assignment] def _get_scoped_secret(name: str, default: Optional[str] = None) -> Optional[str]: """Profile-scope-aware secret read with the default-profile env fallback.""" try: val = _scoped_get_secret(name, default) except _UnscopedSecretError: val = os.getenv(name) return val if val is not None else default logger = logging.getLogger(__name__) PLATFORM_NAME = "nmbr" DEFAULT_BASE_URL = "https://nmbr.ai/api" MAX_MESSAGE_LENGTH = 10000 # nmbr text message limit LONG_POLL_WAIT_SECONDS = 25 # server caps at 25 RECONNECT_BACKOFF = [1, 2, 5, 10, 30] TYPING_MIN_INTERVAL_SECONDS = 4.0 # Hermes pings every 2 s; nmbr needs far less DEDUP_MAX_SIZE = 2000 EXEC_APPROVAL_KIND = "hermes.exec" # card kind for dangerous-command approvals EXEC_APPROVAL_MIN_TTL_SECONDS = 60 # nmbr's minimum expiresAt EXEC_APPROVAL_GRACE_SECONDS = 5 # card outlives Hermes' own wait by this much EXEC_APPROVAL_DEFAULT_TIMEOUT = 60 # Hermes approvals.timeout default PENDING_EXEC_MAX = 200 EXEC_SCOPES = ("once", "session", "always") _NMBR_RE = re.compile(r"^\d{3}-\d{3}-\d{3}$") class _FatalError(Exception): """Unrecoverable (token revoked, owner suspended, …): stop polling.""" def normalize_nmbr(raw: Any) -> Optional[str]: """``123456789`` / ``nmbr:123-456-789`` / ``123-456-789`` → ``123-456-789``; else None.""" if raw is None: return None digits = re.sub(r"\D", "", str(raw).strip().removeprefix("nmbr:")) if len(digits) != 9: return None return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}" # A voice note waits this long for its `message.transcript` before being delivered without one. TRANSCRIPT_WAIT_SECONDS = 45 def _target(chat_id: str) -> Dict[str, str]: """A send target is a nmbr (``to``) or a conversation id (``conversationId``).""" n = normalize_nmbr(chat_id) return {"to": n} if n else {"conversationId": str(chat_id).strip()} def describe_message(m: Dict[str, Any]) -> str: """What the agent sees for a message; non-text types are tagged.""" content = (m.get("content") or "").strip() t = m.get("type") or "text" if t == "text": return content if t == "voice": tr = (m.get("transcript") or "").strip() return f"[voice note] {tr}" if tr else "[voice note — no transcript]" if t == "image": return f"[image] {content}" if content else "[image]" if t == "video": return f"[video] {content}" if content else "[video]" if t == "document": name = (m.get("document") or {}).get("name") head = f"[document: {name}]" if name else "[document]" return f"{head} {content}" if content else head if t == "location": loc = m.get("location") or {} head = f"[location {loc.get('latitude')},{loc.get('longitude')}]" return f"{head} {content}" if content else head if t == "contact": sc = m.get("sharedContact") or {} head = f"[shared contact {sc.get('nmbr', '')}]".replace(" ]", "]") return f"{head} {content}" if content else head return content or f"[{t}]" def _resolve_token(extra: Dict[str, Any]) -> str: return (_get_scoped_secret("NMBR_AGENT_TOKEN", "") or extra.get("token") or "").strip() def _resolve_base_url(extra: Dict[str, Any]) -> str: return (os.getenv("NMBR_BASE_URL", "").strip() or extra.get("base_url") or DEFAULT_BASE_URL).rstrip("/") def check_requirements() -> bool: """Installable and minimally configured? (cheap: env only)""" return HTTPX_AVAILABLE and bool(_get_scoped_secret("NMBR_AGENT_TOKEN", "").strip()) def validate_config(config) -> bool: extra = getattr(config, "extra", {}) or {} return bool(_resolve_token(extra)) def is_connected(config) -> bool: extra = getattr(config, "extra", {}) or {} return bool(_resolve_token(extra)) def hermes_approval_timeout() -> int: """Hermes' ``approvals.timeout`` (seconds) — how long the agent thread waits.""" if _hermes_approval_timeout is not None: try: return max(int(_hermes_approval_timeout()), 0) except Exception: pass return EXEC_APPROVAL_DEFAULT_TIMEOUT def exec_approval_ttl_seconds(timeout: Optional[int] = None) -> int: """Card lifetime: Hermes' wait + a small grace, never under nmbr's 60 s minimum.""" t = hermes_approval_timeout() if timeout is None else timeout return max(EXEC_APPROVAL_MIN_TTL_SECONDS, t + EXEC_APPROVAL_GRACE_SECONDS) def exec_approval_description(command: str, description: str, *, allow_permanent: bool, smart_denied: bool) -> str: """Card body (≤ 2000 chars): the command, Hermes' reason, and how to widen the scope.""" cmd = command if len(command) <= 1200 else command[:1200] + "…" lines = [cmd, "", f"Reason: {description}"] if smart_denied: lines += ["", "Smart DENY — owner override applies to this one operation only."] else: scopes = "\"session\" (this session) or \"always\" (permanently)" if allow_permanent else "\"session\" (this session)" lines += ["", f"Approve runs it once. To remember this pattern, edit scope to {scopes} before approving."] return "\n".join(lines)[:2000] def exec_scope_from_action(action: Dict[str, Any], *, allow_permanent: bool, smart_denied: bool) -> str: """Map the (possibly edited) card payload to Hermes' choice: once | session | always.""" edited = action.get("editedPayload") scope = (edited or {}).get("scope") if isinstance(edited, dict) else None scope = str(scope).strip().lower() if scope is not None else "once" if scope not in EXEC_SCOPES or smart_denied: return "once" if scope == "always" and not allow_permanent: return "session" return scope class NmbrAdapter(BasePlatformAdapter): """Long-poll the nmbr Agent API; reply over it.""" MAX_MESSAGE_LENGTH = MAX_MESSAGE_LENGTH def __init__(self, config: PlatformConfig): super().__init__(config=config, platform=Platform(PLATFORM_NAME)) extra = config.extra or {} self._token: str = _resolve_token(extra) self._base_url: str = _resolve_base_url(extra) self._client: Optional["httpx.AsyncClient"] = None self._poll_task: Optional[asyncio.Task] = None self._seq: int = 0 self._me: Dict[str, Any] = {} self._seen: Dict[str, float] = {} self._held_voice: Dict[str, Any] = {} self._last_typing: Dict[str, float] = {} # Newest inbound message id per conversation: replies to the latest # message go top-level (like a person would); reply_to is only kept # when answering an OLDER message, where the quote disambiguates. self._last_inbound: Dict[str, str] = {} # Exec-approval cards in flight: nmbr action id → {session_key, chat_id, …}. self._pending_exec: Dict[str, Dict[str, Any]] = {} # -- HTTP ---------------------------------------------------------------- def _headers(self) -> Dict[str, str]: return { "Authorization": f"Bearer {self._token}", "Content-Type": "application/json", "Accept": "application/json", "User-Agent": "hermes-nmbr/0.2", } async def _api(self, method: str, path: str, *, json: Any = None, params: Any = None, timeout: float = 15.0) -> Any: assert self._client is not None resp = await self._client.request(method, f"{self._base_url}{path}", json=json, params=params, headers=self._headers(), timeout=timeout) if resp.status_code >= 400: try: err = (resp.json() or {}).get("error") or {} except Exception: err = {} code = err.get("code") or f"http_{resp.status_code}" msg = err.get("message") or resp.text[:200] if resp.status_code in (401, 403): raise _FatalError(f"{code}: {msg}") raise RuntimeError(f"{code}: {msg}") if resp.status_code == 204 or not resp.content: return None return resp.json() # -- Lifecycle ----------------------------------------------------------- async def connect(self, *, is_reconnect: bool = False) -> bool: if not HTTPX_AVAILABLE: logger.warning("[%s] httpx not installed", self.name) return False if not self._token: logger.warning("[%s] NMBR_AGENT_TOKEN not configured — create an agent in the nmbr app and paste its token", self.name) return False self._client = httpx.AsyncClient(timeout=None) try: self._me = await self._api("GET", "/agent/v1/me") or {} if not is_reconnect: cursor = await self._api("GET", "/agent/v1/updates/cursor") or {} self._seq = int(cursor.get("seq") or 0) except _FatalError as e: logger.error("[%s] Agent API refused the token: %s", self.name, e) self._set_fatal_error("nmbr_unauthorized", f"nmbr rejected the agent token ({e}). Check NMBR_AGENT_TOKEN.", retryable=False) await self._client.aclose() self._client = None return False except Exception as e: logger.error("[%s] Cannot reach the Agent API at %s: %s", self.name, self._base_url, e) await self._client.aclose() self._client = None return False self._poll_task = asyncio.create_task(self._poll_loop()) self._mark_connected() logger.info("[%s] Connected as %s (%s); long-polling from seq %d", self.name, self._me.get("displayName") or "agent", self._me.get("nmbr"), self._seq) try: self._wire_plugin_handlers(None) except Exception: # pragma: no cover - older Hermes pass return True async def disconnect(self) -> None: self._running = False self._mark_disconnected() if self._poll_task: self._poll_task.cancel() try: await self._poll_task except (asyncio.CancelledError, Exception): pass self._poll_task = None if self._client: await self._client.aclose() self._client = None self._seen.clear() for held in self._held_voice.values(): held[1].cancel() self._held_voice.clear() self._pending_exec.clear() logger.info("[%s] Disconnected", self.name) async def _poll_loop(self) -> None: backoff_idx = 0 while self._running: started = time.monotonic() try: page = await self._api( "GET", "/agent/v1/updates", params={"afterSeq": self._seq, "wait": LONG_POLL_WAIT_SECONDS, "limit": 100}, timeout=LONG_POLL_WAIT_SECONDS + 15, ) backoff_idx = 0 except asyncio.CancelledError: return except _FatalError as e: logger.error("[%s] Stopping: %s", self.name, e) self._set_fatal_error("nmbr_unauthorized", f"nmbr Agent API: {e}", retryable=False) return except Exception as e: if not self._running: return delay = RECONNECT_BACKOFF[min(backoff_idx, len(RECONNECT_BACKOFF) - 1)] backoff_idx += 1 logger.warning("[%s] Long-poll error (%s); retrying in %ds", self.name, e, delay) await asyncio.sleep(delay) continue events = (page or {}).get("events") or [] for event in events: try: self._seq = max(self._seq, int(event.get("seq") or 0)) await self._on_event(event) except asyncio.CancelledError: return except Exception as e: logger.error("[%s] Event %s failed: %s", self.name, event.get("id"), e) self._seq = max(self._seq, int((page or {}).get("nextSeq") or 0)) # A real long-poll holds the request up to 25 s. If an empty page # came back instantly (proxy, misbehaving server), don't spin. if not events and time.monotonic() - started < 1.0: await asyncio.sleep(1.0) # -- Inbound ------------------------------------------------------------- async def _on_event(self, event: Dict[str, Any]) -> None: etype = event.get("type") payload = event.get("payload") or {} if etype == "contact.added": user = payload.get("user") or {} logger.info("[%s] %s added the agent as a contact", self.name, user.get("nmbr")) return if etype in ("action.approved", "action.rejected", "action.expired"): await self._on_action_event(etype, payload.get("action") or {}) return if etype == "message.transcript": # nmbr transcribes a voice note AFTER message.received is queued and # sends the words separately; deliver the held note as one turn. msg_id = payload.get("messageId") held = self._held_voice.pop(msg_id, None) if msg_id else None if held: held[1].cancel() transcript = payload.get("transcript") if not held and not transcript: return # masked, nothing waiting: nothing to say base = held[0] if held else {"conversationId": payload.get("conversationId"), "from": payload.get("from") or {}, "message": {"id": msg_id, "type": "voice", "content": "", "createdAt": event.get("ts")}} merged = dict(base) merged["message"] = {**(base.get("message") or {}), "transcript": transcript or ""} await self._dispatch_inbound(event, merged, dedupe_key=f"{msg_id}:transcript") return if etype != "message.received": return message = payload.get("message") or {} if message.get("type") == "voice" and not (message.get("transcript") or "").strip(): msg_id = message.get("id") if msg_id and msg_id not in self._held_voice: loop = asyncio.get_running_loop() handle = loop.call_later(TRANSCRIPT_WAIT_SECONDS, lambda: asyncio.ensure_future(self._flush_held_voice(msg_id))) self._held_voice[msg_id] = (payload, handle, event) return await self._dispatch_inbound(event, payload) async def _flush_held_voice(self, msg_id: str) -> None: held = self._held_voice.pop(msg_id, None) if held: await self._dispatch_inbound(held[2], held[0]) async def _dispatch_inbound(self, event: Dict[str, Any], payload: Dict[str, Any], dedupe_key: Optional[str] = None) -> None: message = payload.get("message") or {} sender = payload.get("from") or {} conversation_id = payload.get("conversationId") sender_nmbr = normalize_nmbr(sender.get("nmbr")) msg_id = message.get("id") or event.get("id") if not conversation_id or not sender_nmbr or not msg_id: return if self._is_duplicate(dedupe_key or msg_id): return text = describe_message(message) if not text: return self._last_inbound[conversation_id] = msg_id source = self.build_source( chat_id=conversation_id, chat_name=sender.get("displayName") or sender_nmbr, chat_type="dm", user_id=sender_nmbr, user_name=sender.get("displayName") or sender_nmbr, message_id=msg_id, ) ts = _parse_ts(message.get("createdAt")) msg_event = MessageEvent( text=text, message_type=MessageType.TEXT, source=source, message_id=msg_id, raw_message=event, reply_to_message_id=message.get("replyToId") or None, timestamp=ts, ) logger.debug("[%s] %s → %s: %s", self.name, sender_nmbr, conversation_id, text[:80]) await self.handle_message(msg_event) def _is_duplicate(self, msg_id: str) -> bool: now = time.time() if len(self._seen) > DEDUP_MAX_SIZE: cutoff = now - 600 self._seen = {k: v for k, v in self._seen.items() if v > cutoff} if msg_id in self._seen: return True self._seen[msg_id] = now return False # -- Outbound ------------------------------------------------------------ async def send(self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult: if not self._client: return SendResult(success=False, error="not connected") target = _target(chat_id) body: Dict[str, Any] = {**target, "type": "text", "content": content[: self.MAX_MESSAGE_LENGTH]} if reply_to and reply_to != self._last_inbound.get(target.get("conversationId", "")): body["replyToId"] = reply_to try: res = await self._api("POST", "/agent/v1/messages", json=body) or {} return SendResult(success=True, message_id=(res.get("message") or {}).get("id"), raw_response=res) except _FatalError as e: return SendResult(success=False, error=str(e)) except Exception as e: logger.warning("[%s] Send failed: %s", self.name, e) return SendResult(success=False, error=str(e)) async def send_typing(self, chat_id: str, metadata=None) -> None: """Typing indicator; throttled — Hermes pings every 2 s, nmbr shows it for ~5.""" if not self._client: return target = _target(chat_id) conv = target.get("conversationId") if not conv: return # typing needs a conversation id; nmbr-addressed targets have none yet now = time.monotonic() if now - self._last_typing.get(conv, 0.0) < TYPING_MIN_INTERVAL_SECONDS: return self._last_typing[conv] = now try: await self._api("POST", f"/agent/v1/conversations/{conv}/typing", json={"typing": True}, timeout=5.0) except Exception as e: logger.debug("[%s] typing failed: %s", self.name, e) async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: return {"name": chat_id, "type": "dm", "chat_id": chat_id} # -- Exec approvals (native nmbr cards) ------------------------------------ async def send_exec_approval( self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", metadata: Optional[Dict[str, Any]] = None, allow_permanent: bool = True, smart_denied: bool = False, ) -> SendResult: """Gateway contract (see ``gateway/run.py`` ``_approval_notify_sync``): render Hermes' dangerous-command prompt as a native nmbr approval card. The agent thread is already blocked in ``tools.approval``; the card's decision arrives as an ``action.*`` event and is resolved in :meth:`_on_action_event`. Any failure here returns ``success=False`` so the gateway sends its plain-text ``/approve`` prompt instead. """ del metadata # nmbr 1:1s have no threads if not self._client: return SendResult(success=False, error="not connected") if _resolve_gateway_approval is None: return SendResult(success=False, error="tools.approval unavailable; use the text prompt") if len(self._pending_exec) >= PENDING_EXEC_MAX: return SendResult(success=False, error="too many exec approvals in flight") ttl = exec_approval_ttl_seconds() expires_at = datetime.fromtimestamp(time.time() + ttl, tz=timezone.utc).isoformat().replace("+00:00", "Z") body: Dict[str, Any] = { **_target(chat_id), "kind": EXEC_APPROVAL_KIND, "title": "Smart DENY — run this once anyway?" if smart_denied else "Run this command?", "description": exec_approval_description(command, description, allow_permanent=allow_permanent, smart_denied=smart_denied), "payload": {"scope": "once"}, "expiresAt": expires_at, } try: # The gateway waits ≤ 15 s for this call before falling back to text. res = await self._api("POST", "/agent/v1/actions", json=body, timeout=10.0) or {} except Exception as e: logger.warning("[%s] Approval card failed (%s); gateway falls back to text", self.name, e) return SendResult(success=False, error=str(e)) action = res.get("action") or {} action_id = action.get("id") if not action_id: return SendResult(success=False, error="no action id in response") self._pending_exec[action_id] = { "session_key": session_key, "chat_id": res.get("conversationId") or action.get("conversationId") or chat_id, "allow_permanent": allow_permanent, "smart_denied": smart_denied, "sent_at": time.monotonic(), } logger.info("[%s] Approval card %s sent for session %s (ttl %ds)", self.name, action_id, session_key, ttl) return SendResult(success=True, message_id=(res.get("message") or {}).get("id"), raw_response=res) async def _on_action_event(self, etype: str, action: Dict[str, Any]) -> None: """``action.approved|rejected|expired`` → unblock the waiting agent thread.""" pending = self._pending_exec.pop(str(action.get("id") or ""), None) if pending is None or action.get("kind") != EXEC_APPROVAL_KIND: return # not ours (another card kind, or a card from a previous process) if etype == "action.expired": return # Hermes times out on its own ("silence is not consent"); nothing to resolve session_key = pending["session_key"] chat_id = pending["chat_id"] if etype == "action.approved": choice = exec_scope_from_action(action, allow_permanent=pending["allow_permanent"], smart_denied=pending["smart_denied"]) else: choice = "deny" count = 0 if _resolve_gateway_approval is not None: try: count = _resolve_gateway_approval(session_key, choice) except Exception as e: # pragma: no cover - defensive logger.error("[%s] resolve_gateway_approval failed for %s: %s", self.name, session_key, e) resume = getattr(self, "resume_typing_for_chat", None) if callable(resume): resume(chat_id) if not count: text = "⌛ Too late — the request had already timed out or been answered, so nothing was run." elif choice == "deny": text = "❌ Denied. The command was not run." elif choice == "once": text = "✅ Approved — running it this once." elif choice == "session": text = "✅ Approved — this pattern is allowed for the rest of the session." else: text = "✅ Approved — this pattern is now allowed permanently." logger.info("[%s] Card %s → %s for session %s (%d resolved)", self.name, action.get("id"), choice, session_key, count) await self.send(chat_id, text) def _parse_ts(raw: Any) -> datetime: try: if isinstance(raw, str) and raw: return datetime.fromisoformat(raw.replace("Z", "+00:00")) except ValueError: pass return datetime.now(tz=timezone.utc) # -- Plugin hooks -------------------------------------------------------------- def _env_enablement() -> Optional[dict]: token = _get_scoped_secret("NMBR_AGENT_TOKEN", "").strip() if not token: return None seed: dict = {"token": token, "base_url": _resolve_base_url({})} home = os.getenv("NMBR_HOME_CHANNEL", "").strip() if home: seed["home_channel"] = {"chat_id": normalize_nmbr(home) or home, "name": os.getenv("NMBR_HOME_CHANNEL_NAME", "").strip() or home} return seed async def _standalone_send(pconfig, chat_id: str, message: str, *, thread_id: Optional[str] = None, media_files: Optional[List[str]] = None, force_document: bool = False) -> Dict[str, Any]: """Out-of-process send for cron / send_message when the gateway isn't in this process.""" if not HTTPX_AVAILABLE: return {"error": "nmbr standalone send: httpx not installed"} extra = getattr(pconfig, "extra", {}) or {} token = _resolve_token(extra) if not token: return {"error": "nmbr standalone send: NMBR_AGENT_TOKEN not configured"} base = _resolve_base_url(extra) body = {**_target(chat_id), "type": "text", "content": message[:MAX_MESSAGE_LENGTH]} try: async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.post(f"{base}/agent/v1/messages", json=body, headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json", "User-Agent": "hermes-nmbr/0.2"}) if resp.status_code >= 300: return {"error": f"nmbr HTTP {resp.status_code}: {resp.text[:200]}"} data = resp.json() return {"success": True, "platform": PLATFORM_NAME, "chat_id": data.get("conversationId") or chat_id, "message_id": (data.get("message") or {}).get("id")} except Exception as e: return {"error": f"nmbr standalone send failed: {e}"} def register(ctx) -> None: """Plugin entry point — called by the Hermes plugin system at startup.""" ctx.register_platform( name=PLATFORM_NAME, label="nmbr", adapter_factory=lambda cfg: NmbrAdapter(cfg), check_fn=check_requirements, validate_config=validate_config, is_connected=is_connected, required_env=["NMBR_AGENT_TOKEN"], install_hint="pip install httpx # already a Hermes dependency", env_enablement_fn=_env_enablement, cron_deliver_env_var="NMBR_HOME_CHANNEL", standalone_sender_fn=_standalone_send, allowed_users_env="NMBR_ALLOWED_USERS", allow_all_env="NMBR_ALLOW_ALL_USERS", max_message_length=MAX_MESSAGE_LENGTH, emoji="📱", pii_safe=False, allow_update_command=True, platform_hint=( "You are chatting over nmbr, a private messaging app, in a 1:1 with the person who owns you. " "Plain text only — no markdown. Keep replies conversational and phone-sized. " "Dangerous commands show up as an approval card the person taps (Approve / Reject); typing /approve or /deny also works." ), )