{"repo":"NousResearch/hermes-agent","public_id":"21804","display_label":"Plugin cron delivery and standalone sender support","summary":"These items address a bug where cron jobs with plugin-based delivery targets (IRC, Teams, Google Chat) fail out-of-process, and related feature work to add standalone sender functions and dynamic system-prompt hints for plugin platforms.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":22461},"resolution":{"state":"partially_resolved","by":"pr-22461"},"freshness":{"state":"verified","last_verified_at":"2026-05-09T09:56:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.33599999999999997},"fix_edges":[{"issue_number":21804,"pr_number":22461,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":21804,"title":"[Bug]: cron deliver= to plugin platforms (IRC, Teams, Google Chat) fails with 'No live adapter' when run out-of-process","url":null,"state":"closed"},{"kind":"issue","number":64899,"title":"[Feature]: Plugin platforms need dynamic system-prompt hints and standalone send paths","url":null,"state":"closed"},{"kind":"pr","number":21805,"title":"feat(plugins): add standalone_sender_fn for out-of-process cron delivery","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":22461,"title":"feat(plugins): add standalone_sender_fn for out-of-process cron delivery","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platform_registry.py","gateway/platforms/ADDING_A_PLATFORM.md","plugins/platforms/google_chat/adapter.py","plugins/platforms/irc/adapter.py","plugins/platforms/teams/adapter.py","tests/gateway/test_google_chat.py","tests/gateway/test_irc_adapter.py","tests/gateway/test_teams.py","tests/tools/test_send_message_tool.py","tools/send_message_tool.py","website/docs/developer-guide/adding-platform-adapters.md"],"fix_hunks":[{"file":"gateway/platform_registry.py","hunks":["@@ -30,7 +30,7 @@\n \n import logging\n from dataclasses import dataclass, field\n-from typing import Any, Callable, Optional\n+from typing import Any, Awaitable, Callable, Optional\n \n logger = logging.getLogger(__name__)\n ","@@ -125,6 +125,23 @@ class PlatformEntry:\n     # resolve the default chat/room ID.  Empty = no cron home-channel support.\n     cron_deliver_env_var: str = \"\"\n \n+    # ── Standalone (out-of-process) sending ──\n+    # Optional: async coroutine that delivers a message without a live\n+    # gateway adapter.  Called by ``tools/send_message_tool._send_via_adapter``\n+    # when ``cron`` runs in a separate process from the gateway and the\n+    # in-process adapter weakref is therefore ``None``.\n+    #\n+    # Signature:\n+    #     async (pconfig, chat_id, message, *, thread_id=None,\n+    #            media_files=None, force_document=False) -> dict\n+    #\n+    # Returns ``{\"success\": True, \"message_id\": ...}`` on success or\n+    # ``{\"error\": str}`` on failure.  Plugin authors typically open an\n+    # ephemeral connection / acquire a fresh OAuth token, send, and close.\n+    # Without this hook, plugin platforms cannot serve as cron ``deliver=``\n+    # targets when the gateway is not co-resident with the cron process.\n+    standalone_sender_fn: Optional[Callable[..., Awaitable[dict]]] = None\n+\n \n class PlatformRegistry:\n     \"\"\"Central registry of platform adapters."]},{"file":"gateway/platforms/ADDING_A_PLATFORM.md","hunks":["@@ -14,7 +14,7 @@ The plugin system automatically handles: adapter creation, config parsing,\n user authorization, cron delivery, send_message routing, system prompt hints,\n status display, gateway setup, and more.\n \n-**Three optional hooks cover the edges most adapters need:**\n+**Optional hooks cover the edges most adapters need:**\n \n - `env_enablement_fn: () -> Optional[dict]` — seeds `PlatformConfig.extra`\n   (and an optional `home_channel` dict) from env vars BEFORE the adapter is","@@ -24,6 +24,11 @@ status display, gateway setup, and more.\n - `cron_deliver_env_var: str` — name of the `*_HOME_CHANNEL` env var.  When\n   set, `deliver=<name>` cron jobs route to this var without editing\n   `cron/scheduler.py`'s hardcoded sets.\n+- `standalone_sender_fn: async (...) -> dict`: out-of-process delivery\n+  for cron jobs that run separately from the gateway.  Without this, a\n+  `deliver=<name>` job fires correctly but the actual send returns\n+  `No live adapter for platform '<name>'`.  Pair with `cron_deliver_env_var`\n+  for end-to-end cron support.  See the docsite for the signature.\n - `plugin.yaml` `requires_env` / `optional_env` rich-dict entries —\n   auto-populate `OPTIONAL_ENV_VARS` in `hermes_cli/config.py` so the setup\n   wizard surfaces proper descriptions, prompts, password flags, and URLs."]},{"file":"plugins/platforms/google_chat/adapter.py","hunks":["@@ -3036,6 +3036,165 @@ def interactive_setup() -> None:\n     print_info(\"Restart the gateway: hermes gateway restart\")\n \n \n+# Strict resource-name pattern.  ``spaces/<id>`` and ``users/<id>`` must\n+# only contain Google Chat's documented character set; anything else\n+# means a tampered chat_id trying to break out of the REST URL path\n+# (path traversal, ``?`` query injection, ``#`` fragment truncation).\n+_GCHAT_CHAT_ID_RE = re.compile(r\"^(?:spaces|users)/[A-Za-z0-9_-]+$\")\n+\n+\n+async def _standalone_send(\n+    pconfig,\n+    chat_id: str,\n+    message: str,\n+    *,\n+    thread_id: Optional[str] = None,\n+    media_files: Optional[List[str]] = None,\n+    force_document: bool = False,\n+) -> Dict[str, Any]:\n+    \"\"\"POST a single Google Chat message via the REST API without the SDK.\n+\n+    Used by ``tools/send_message_tool._send_via_adapter`` when the gateway\n+    runner is not in this process (e.g. ``hermes cron`` running as a\n+    separate process from ``hermes gateway``).  Without this hook,\n+    ``deliver=google_chat`` cron jobs fail with ``No live adapter for\n+    platform``.\n+\n+    Configuration: requires service-account credentials via\n+    ``GOOGLE_CHAT_SERVICE_ACCOUNT_JSON``, ``GOOGLE_APPLICATION_CREDENTIALS``,\n+    or Application Default Credentials, and a space resource name as\n+    ``chat_id`` (e.g. ``spaces/AAAA-BBBB`` or ``users/<id>``).\n+\n+    Security: ``chat_id`` is validated against the documented Google Chat\n+    resource-name character set before substitution into the REST URL so\n+    a tampered value cannot path-traverse or query-inject.\n+\n+    ``media_files`` and ``force_document`` are accepted for signature\n+    parity but are not implemented for the standalone path; messages with\n+    attachments send as text-only.  The live adapter handles attachments.\n+    \"\"\"\n+    if not chat_id:\n+        return {\"error\": \"Google Chat standalone send: chat_id (space resource) is required\"}\n+    if not _GCHAT_CHAT_ID_RE.match(chat_id):\n+        return {\"error\": (\n+            f\"Google Chat standalone send: chat_id {chat_id!r} must match \"\n+            f\"'spaces/<id>' or 'users/<id>' with only [A-Za-z0-9_-] in the id\"\n+        )}\n+    if thread_id is not None and not re.match(r\"^spaces/[A-Za-z0-9_-]+/threads/[A-Za-z0-9_-]+$\", thread_id):\n+        return {\"error\": (\n+            f\"Google Chat standalone send: thread_id {thread_id!r} must match \"\n+            f\"'spaces/<id>/threads/<id>'\"\n+        )}\n+\n+    extra = getattr(pconfig, \"extra\", {}) or {}\n+    sa_value = (\n+        extra.get(\"service_account_json\")\n+        or os.getenv(\"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON\")\n+        or os.getenv(\"GOOGLE_APPLICATION_CREDENTIALS\")\n+    )\n+\n+    if service_account is None:\n+        return {\"error\": \"Google Chat standalone send: google-auth not installed\"}\n+\n+    try:\n+        from google.auth.transport.requests import Request as _GoogleAuthRequest\n+    except Exception as e:\n+        return {\"error\": f\"Google Chat standalone send: google-auth import failed: {e}\"}\n+\n+    try:\n+        if sa_value:\n+            stripped = sa_value.lstrip()\n+            if stripped.startswith(\"{\"):\n+                try:\n+                    info = json.loads(sa_value)\n+                except json.JSONDecodeError as exc:\n+                    return {\"error\": f\"Google Chat standalone send: inline SA JSON is invalid: {exc}\"}\n+                creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES)\n+            else:\n+                if not os.path.exists(sa_value):\n+                    return {\"error\": f\"Google Chat standalone send: SA JSON file not found at {sa_value}\"}\n+                try:\n+                    with open(sa_value, \"r\", encoding=\"utf-8\") as fh:\n+                        info = json.load(fh)\n+                except json.JSONDecodeError as exc:\n+                    return {\"error\": f\"Google Chat standalone send: SA JSON file is invalid: {exc}\"}\n+                creds = service_account.Credentials.from_service_account_info(info, scopes=_CHAT_SCOPES)\n+        else:\n+            try:\n+                import google.auth as _google_auth\n+            except ImportError:\n+                return {\"error\": (\n+                    \"Google Chat standalone send: no SA credentials configured \"\n+                    \"and google-auth is not installed for ADC fallback\"\n+                )}\n+            try:\n+                creds, _project = _google_auth.default(scopes=_CHAT_SCOPES)\n+            except Exception as exc:\n+                return {\"error\": (\n+                    f\"Google Chat standalone send: no SA credentials configured \"\n+                    f\"and Application Default Credentials are unavailable: {exc}\"\n+                )}\n+    except asyncio.CancelledError:\n+        raise\n+    except Exception as e:\n+        return {\"error\": f\"Google Chat standalone send: credential load failed: {e}\"}\n+\n+    # Bound the synchronous urllib3-backed token refresh so a hung Google\n+    # STS endpoint cannot stall the cron scheduler indefinitely.\n+    try:\n+        await asyncio.wait_for(\n+            asyncio.to_thread(creds.refresh, _GoogleAuthRequest()),\n+            timeout=10.0,\n+        )\n+    except asyncio.TimeoutError:\n+        return {\"error\": \"Google Chat standalone send: token refresh timed out\"}\n+    except asyncio.CancelledError:\n+        raise\n+    except Exception as e:\n+        return {\"error\": f\"Google Chat standalone send: token refresh failed: {e}\"}\n+\n+    token = [redacted:secret-assignment] \"token\", None)\n+    if not token:\n+        return {\"error\": \"Google Chat standalone send: refreshed credentials have no token\"}\n+\n+    body: Dict[str, Any] = {\"text\": message}\n+    if thread_id:\n+        body[\"thread\"] = {\"name\": thread_id}\n+\n+    url = f\"https://chat.googleapis.com/v1/{chat_id}/messages\"\n+    try:\n+        import aiohttp as _aiohttp\n+    except ImportError:\n+        return {\"error\": \"Google Chat standalone send: aiohttp not installed\"}\n+\n+    try:\n+        async with _aiohttp.ClientSession(timeout=_aiohttp.ClientTimeout(total=30.0)) as session:\n+            async with session.post(\n+                url,\n+                json=body,\n+                headers={\n+                    \"Authorization\": f\"Bearer {token}\",\n+                    \"Content-Type\": \"application/json\",\n+                },\n+            ) as resp:\n+                if resp.status >= 400:\n+                    text = await resp.text()\n+                    return {\"error\": (\n+                        f\"Google Chat standalone send: API returned \"\n+                        f\"{resp.status}: {text[:300]}\"\n+                    )}\n+                payload = await resp.json()\n+        return {\n+            \"success\": True,\n+            \"message_id\": payload.get(\"name\"),\n+        }\n+    except asyncio.CancelledError:\n+        raise\n+    except Exception as e:\n+        logger.debug(\"Google Chat standalone send raised\", exc_info=True)\n+        return {\"error\": f\"Google Chat standalone send failed: {e}\"}\n+\n+\n def register(ctx) -> None:\n     \"\"\"Plugin entry point — called by the Hermes plugin system at startup.\n ","@@ -3069,6 +3228,10 @@ def register(ctx) -> None:\n         # cron jobs route to the configured home space without editing\n         # cron/scheduler.py's hardcoded sets.\n         cron_deliver_env_var=\"GOOGLE_CHAT_HOME_CHANNEL\",\n+        # Out-of-process cron delivery via the Chat REST API.  Without this\n+        # hook, deliver=google_chat cron jobs fail with \"No live adapter\"\n+        # when cron runs separately from the gateway.\n+        standalone_sender_fn=_standalone_send,\n         # Auth env vars for _is_user_authorized() integration.\n         allowed_users_env=\"GOOGLE_CHAT_ALLOWED_USERS\",\n         allow_all_env=\"GOOGLE_CHAT_ALLOW_ALL_USERS\","]},{"file":"plugins/platforms/irc/adapter.py","hunks":["@@ -53,11 +53,6 @@\n from gateway.config import PlatformConfig, Platform\n \n \n-def _ensure_imports():\n-    \"\"\"No-op — kept for backward compatibility with any call sites.\"\"\"\n-    pass\n-\n-\n # ---------------------------------------------------------------------------\n # IRC protocol helpers\n # ---------------------------------------------------------------------------","@@ -704,8 +699,233 @@ def _env_enablement() -> dict | None:\n     return seed\n \n \n+def _strip_irc_control_chars(text: str) -> str:\n+    \"\"\"Strip IRC line terminators and the NUL byte from ``text``.\n+\n+    IRC commands are CRLF-delimited; a bare ``\\\\r`` or ``\\\\n`` in user\n+    content lets an attacker inject arbitrary IRC commands (CTCP, JOIN,\n+    KICK).  ``\\\\x00`` is a protocol-illegal byte.  Everything else is\n+    valid in PRIVMSG payloads.\n+    \"\"\"\n+    return text.replace(\"\\r\", \" \").replace(\"\\n\", \" \").replace(\"\\x00\", \"\")\n+\n+\n+def _is_irc_channel(target: str) -> bool:\n+    return bool(target) and target[0] in \"#&+!\"\n+\n+\n+async def _standalone_send(\n+    pconfig,\n+    chat_id: str,\n+    message: str,\n+    *,\n+    thread_id: Optional[str] = None,\n+    media_files: Optional[List[str]] = None,\n+    force_document: bool = False,\n+) -> Dict[str, Any]:\n+    \"\"\"Open an ephemeral IRC connection, send a PRIVMSG, and quit.\n+\n+    Used by ``tools/send_message_tool._send_via_adapter`` when the gateway\n+    runner is not in this process (e.g. ``hermes cron`` running as a\n+    separate process from ``hermes gateway``).  Without this hook,\n+    ``deliver=irc`` cron jobs fail with ``No live adapter for platform``.\n+\n+    The standalone client uses a distinct nick suffix (``-cron``) so it\n+    does not collide with the long-running gateway adapter that may already\n+    be holding the configured nickname on the same network.  When the\n+    target is a channel, the client JOINs it before sending PRIVMSG so\n+    networks with the default ``+n`` (no external messages) channel mode\n+    accept the delivery.\n+\n+    ``thread_id`` and ``media_files`` are accepted for signature parity but\n+    are not meaningful on IRC: IRC has no native thread or attachment\n+    primitive.\n+    \"\"\"\n+    extra = getattr(pconfig, \"extra\", {}) or {}\n+    server = os.getenv(\"IRC_SERVER\") or extra.get(\"server\", \"\")\n+    channel = os.getenv(\"IRC_CHANNEL\") or extra.get(\"channel\", \"\")\n+    if not server or not channel:\n+        return {\"error\": \"IRC standalone send: IRC_SERVER and IRC_CHANNEL must be configured\"}\n+\n+    port_value = os.getenv(\"IRC_PORT\") or extra.get(\"port\", 6697)\n+    try:\n+        port = int(port_value)\n+    except (TypeError, ValueError):\n+        return {\"error\": f\"IRC standalone send: invalid port {port_value!r}\"}\n+\n+    nickname = os.getenv(\"IRC_NICKNAME\") or extra.get(\"nickname\", \"hermes-bot\")\n+    use_tls_env = os.getenv(\"IRC_USE_TLS\")\n+    if use_tls_env is not None:\n+        use_tls = use_tls_env.lower() in (\"1\", \"true\", \"yes\")\n+    else:\n+        use_tls = bool(extra.get(\"use_tls\", True))\n+\n+    server_password = [redacted:secret-assignment]\"IRC_SERVER_PASSWORD\") or extra.get(\"server_password\", \"\")\n+    nickserv_password = [redacted:secret-assignment]\"IRC_NICKSERV_PASSWORD\") or extra.get(\"nickserv_password\", \"\")\n+\n+    # Reject control characters in chat_id to block IRC command injection.\n+    raw_target = chat_id or channel\n+    if any(ch in raw_target for ch in (\"\\r\", \"\\n\", \"\\x00\", \" \")):\n+        return {\"error\": \"IRC standalone send: chat_id contains illegal IRC characters\"}\n+    target = raw_target\n+\n+    # Distinct nick prevents NICK collision with a live gateway adapter\n+    # that may already be holding the configured nickname.  Cap to 24 chars\n+    # so subsequent collision retries do not overflow the 30-char NICKLEN\n+    # most networks enforce.\n+    nick_base = nickname.rstrip(\"_0123456789-\")[:24] or \"hermes-bot\"\n+    standalone_nick = f\"{nick_base}-cron\"[:30]\n+    plain = IRCAdapter._strip_markdown(message)\n+\n+    ssl_ctx = ssl.create_default_context() if use_tls else None\n+    try:\n+        reader, writer = await asyncio.wait_for(\n+            asyncio.open_connection(server, port, ssl=ssl_ctx),\n+            timeout=15.0,\n+        )\n+    except asyncio.CancelledError:\n+        raise\n+    except Exception as e:\n+        return {\"error\": f\"IRC standalone connect failed: {e}\"}\n+\n+    async def _raw(line: str) -> None:\n+        writer.write((line + \"\\r\\n\").encode(\"utf-8\"))\n+        await writer.drain()\n+\n+    nick_attempts = 0\n+    max_nick_attempts = 5\n+    try:\n+        if server_password:\n+            await _raw(f\"PASS {_strip_irc_control_chars(server_password)}\")\n+        await _raw(f\"NICK {standalone_nick}\")\n+        await _raw(f\"USER {standalone_nick} 0 * :Hermes Agent (cron)\")\n+\n+        loop = asyncio.get_running_loop()\n+        deadline = loop.time() + 15.0\n+        registered = False\n+        while not registered:\n+            remaining = deadline - loop.time()\n+            if remaining <= 0:\n+                return {\"error\": \"IRC standalone send: registration timeout (no RPL_WELCOME)\"}\n+            try:\n+                raw_line = await asyncio.wait_for(reader.readuntil(b\"\\r\\n\"), timeout=remaining)\n+            except asyncio.TimeoutError:\n+                return {\"error\": \"IRC standalone send: registration timeout (no RPL_WELCOME)\"}\n+            except asyncio.IncompleteReadError:\n+                return {\"error\": \"IRC standalone send: server closed connection during registration\"}\n+            decoded = raw_line.decode(\"utf-8\", errors=\"replace\").rstrip(\"\\r\\n\")\n+            msg = _parse_irc_message(decoded)\n+            cmd = msg[\"command\"]\n+            if cmd == \"PING\":\n+                payload = msg[\"params\"][0] if msg[\"params\"] else \"\"\n+                await _raw(f\"PONG :{payload}\")\n+            elif cmd == \"001\":\n+                registered = True\n+            elif cmd in (\"432\", \"433\"):\n+                nick_attempts += 1\n+                if nick_attempts > max_nick_attempts:\n+                    return {\"error\": \"IRC standalone send: too many nick collisions\"}\n+                # Build the next nick from the stable base, not the\n+                # mutated value, so the suffix stays bounded.\n+                standalone_nick = f\"{nick_base}-cron-{nick_attempts}\"[:30]\n+                await _raw(f\"NICK {standalone_nick}\")\n+            elif cmd in (\"464\", \"465\"):\n+                return {\"error\": f\"IRC standalone send: server rejected client ({cmd})\"}\n+\n+        if nickserv_password:\n+            await _raw(f\"PRIVMSG NickServ :IDENTIFY {_strip_irc_control_chars(nickserv_password)}\")\n+            await asyncio.sleep(2)\n+\n+        # JOIN before PRIVMSG.  IRC channels with the default ``+n`` mode\n+        # (no external messages: Libera, OFTC, EFnet, IRCNet, undernet)\n+        # silently drop PRIVMSG from non-members.  Do not JOIN bare nicks\n+        # (DM target) or server queries.\n+        if _is_irc_channel(target):\n+            await _raw(f\"JOIN {target}\")\n+            join_deadline = loop.time() + 5.0\n+            joined = False\n+            while not joined:\n+                remaining = join_deadline - loop.time()\n+                if remaining <= 0:\n+                    # Timed out waiting for a JOIN ack: proceed anyway, the\n+                    # server may still deliver the PRIVMSG depending on mode.\n+                    break\n+                try:\n+                    raw_line = await asyncio.wait_for(reader.readuntil(b\"\\r\\n\"), timeout=remaining)\n+                except (asyncio.TimeoutError, asyncio.IncompleteReadError):\n+                    break\n+                decoded = raw_line.decode(\"utf-8\", errors=\"replace\").rstrip(\"\\r\\n\")\n+                jmsg = _parse_irc_message(decoded)\n+                jcmd = jmsg[\"command\"]\n+                if jcmd == \"PING\":\n+                    payload = jmsg[\"params\"][0] if jmsg[\"params\"] else \"\"\n+                    await _raw(f\"PONG :{payload}\")\n+                elif jcmd in (\"366\", \"JOIN\"):\n+                    joined = True\n+                elif jcmd in (\"403\", \"405\", \"471\", \"473\", \"474\", \"475\"):\n+                    return {\"error\": f\"IRC standalone send: JOIN {target} rejected ({jcmd})\"}\n+\n+        # Bytes-aware per-line splitting so multi-line plain text never\n+        # exceeds the IRC 510-byte protocol limit.  Reuses the same\n+        # algorithm as IRCAdapter._split_message, with control-character\n+        # stripping per line to block CRLF injection from message content.\n+        overhead = len(f\"PRIVMSG {target} :\".encode(\"utf-8\")) + 2\n+        max_bytes = 510 - overhead\n+        sent_any = False\n+        for paragraph in plain.split(\"\\n\"):\n+            paragraph = _strip_irc_control_chars(paragraph).rstrip()\n+            if not paragraph:\n+                continue\n+            while paragraph:\n+                encoded = paragraph.encode(\"utf-8\")\n+                if len(encoded) <= max_bytes:\n+                    await _raw(f\"PRIVMSG {target} :{paragraph}\")\n+                    await asyncio.sleep(0.3)\n+                    sent_any = True\n+                    break\n+                # Binary search for largest prefix that fits within max_bytes\n+                low, high, best = 1, len(paragraph), 0\n+                while low <= high:\n+                    mid = (low + high) // 2\n+                    if len(paragraph[:mid].encode(\"utf-8\")) <= max_bytes:\n+                        best = mid\n+                        low = mid + 1\n+                    else:\n+                        high = mid - 1\n+                split_at = best\n+                space = paragraph.rfind(\" \", 0, split_at)\n+                if space > split_at // 3:\n+                    split_at = space\n+                await _raw(f\"PRIVMSG {target} :{paragraph[:split_at].rstrip()}\")\n+                await asyncio.sleep(0.3)\n+                sent_any = True\n+                paragraph = paragraph[split_at:].lstrip()\n+\n+        if not sent_any:\n+            return {\"error\": \"IRC standalone send: empty message after stripping\"}\n+\n+        await _raw(\"QUIT :delivered\")\n+        try:\n+            await asyncio.wait_for(reader.read(1024), timeout=2.0)\n+        except asyncio.TimeoutError:\n+            pass\n+\n+        return {\"success\": True, \"message_id\": str(int(time.time() * 1000))}\n+    except asyncio.CancelledError:\n+        raise\n+    except Exception as e:\n+        logger.debug(\"IRC standalone send raised\", exc_info=True)\n+        return {\"error\": f\"IRC standalone send failed: {e}\"}\n+    finally:\n+        try:\n+            writer.close()\n+            await asyncio.wait_for(writer.wait_closed(), timeout=5.0)\n+        except (asyncio.TimeoutError, Exception):\n+            pass\n+\n+\n def register(ctx):\n-    \"\"\"Plugin entry point — called by the Hermes plugin system.\"\"\"\n+    \"\"\"Plugin entry point: called by the Hermes plugin system.\"\"\"\n     ctx.register_platform(\n         name=\"irc\",\n         label=\"IRC\",","@@ -716,7 +936,7 @@ def register(ctx):\n         required_env=[\"IRC_SERVER\", \"IRC_CHANNEL\", \"IRC_NICKNAME\"],\n         install_hint=\"No extra packages needed (stdlib only)\",\n         setup_fn=interactive_setup,\n-        # Env-driven auto-configuration — seeds PlatformConfig.extra with\n+        # Env-driven auto-configuration: seeds PlatformConfig.extra with\n         # server/channel/port/tls + home_channel so env-only setups show\n         # up in gateway status without instantiating the adapter.\n         env_enablement_fn=_env_enablement,","@@ -724,6 +944,10 @@ def register(ctx):\n         # IRC_CHANNEL (see _env_enablement), so cron jobs with\n         # deliver=irc route to the joined channel by default.\n         cron_deliver_env_var=\"IRC_HOME_CHANNEL\",\n+        # Out-of-process cron delivery.  Without this hook, deliver=irc\n+        # cron jobs fail with \"No live adapter\" when cron runs separately\n+        # from the gateway.\n+        standalone_sender_fn=_standalone_send,\n         # Auth env vars for _is_user_authorized() integration\n         allowed_users_env=\"IRC_ALLOWED_USERS\",\n         allow_all_env=\"IRC_ALLOW_ALL_USERS\","]},{"file":"plugins/platforms/teams/adapter.py","hunks":["@@ -418,6 +418,9 @@ def _env_enablement() -> dict | None:\n             seed[\"port\"] = int(port)\n         except ValueError:\n             pass\n+    service_url = os.getenv(\"TEAMS_SERVICE_URL\", \"\").strip()\n+    if service_url:\n+        seed[\"service_url\"] = service_url\n     home = os.getenv(\"TEAMS_HOME_CHANNEL\", \"\").strip()\n     if home:\n         seed[\"home_channel\"] = {","@@ -427,6 +430,173 @@ def _env_enablement() -> dict | None:\n     return seed\n \n \n+# Bot Framework default service URL for the global Teams endpoint.  Some\n+# regional/government tenants need a different host (e.g.\n+# ``https://smba.infra.gov.teams.microsoft.us/``) which can be supplied via\n+# ``TEAMS_SERVICE_URL`` or ``extra['service_url']``.\n+_DEFAULT_TEAMS_SERVICE_URL = \"https://smba.trafficmanager.net/teams/\"\n+\n+# Allowlist of Bot Framework service hosts that may receive a freshly\n+# minted bearer token.  Operator-supplied URLs are matched against this\n+# allowlist to block SSRF / token-exfiltration via a tampered env var.\n+_ALLOWED_TEAMS_SERVICE_HOSTS = frozenset({\n+    \"smba.trafficmanager.net\",\n+    \"smba.infra.gov.teams.microsoft.us\",\n+})\n+\n+# Conservative pattern for Bot Framework conversation IDs.  Real values\n+# combine digits, colons, hyphens, dots, '@', and the ``thread.skype`` /\n+# ``thread.tacv2`` suffixes; reject anything outside this set so a hostile\n+# value cannot path-traverse out of ``/v3/conversations/<id>/activities``.\n+import re as _re_teams\n+_TEAMS_CONV_ID_RE = _re_teams.compile(r\"^[A-Za-z0-9:@\\-_.]+$\")\n+\n+\n+def _validate_teams_service_url(raw: str) -> Optional[str]:\n+    \"\"\"Return a normalized service URL or ``None`` if it is not allowed.\n+\n+    Requires ``https://`` and a host in ``_ALLOWED_TEAMS_SERVICE_HOSTS``.\n+    The trailing slash is added if absent so callers can append\n+    ``v3/conversations/...`` without double slashes.\n+    \"\"\"\n+    if not raw:\n+        return None\n+    try:\n+        from urllib.parse import urlparse\n+\n+        parsed = urlparse(raw)\n+    except Exception:\n+        return None\n+    if parsed.scheme != \"https\":\n+        return None\n+    if parsed.hostname not in _ALLOWED_TEAMS_SERVICE_HOSTS:\n+        return None\n+    normalized = raw if raw.endswith(\"/\") else raw + \"/\"\n+    return normalized\n+\n+\n+async def _standalone_send(\n+    pconfig,\n+    chat_id: str,\n+    message: str,\n+    *,\n+    thread_id: Optional[str] = None,\n+    media_files: Optional[list] = None,\n+    force_document: bool = False,\n+) -> Dict[str, Any]:\n+    \"\"\"Acquire a Bot Framework bearer token and POST a single message activity.\n+\n+    Used by ``tools/send_message_tool._send_via_adapter`` when the gateway\n+    runner is not in this process (e.g. ``hermes cron`` running as a\n+    separate process from ``hermes gateway``).  Without this hook,\n+    ``deliver=teams`` cron jobs fail with ``No live adapter for platform``.\n+\n+    Configuration: requires ``TEAMS_CLIENT_ID``, ``TEAMS_CLIENT_SECRET``,\n+    ``TEAMS_TENANT_ID``, ``TEAMS_HOME_CHANNEL`` (the conversation ID), and\n+    optionally ``TEAMS_SERVICE_URL`` (Bot Framework service host; must be\n+    a known Bot Framework endpoint, see ``_ALLOWED_TEAMS_SERVICE_HOSTS``).\n+\n+    Security: ``service_url`` is validated against an allowlist of known\n+    Bot Framework hosts to block SSRF / token-exfiltration via a tampered\n+    env var.  ``chat_id`` is validated to match the documented Bot\n+    Framework ID character set so it cannot escape the URL path.\n+\n+    ``media_files`` and ``force_document`` are accepted for signature\n+    parity but not implemented for the standalone path; messages with\n+    attachments will send as text-only.  The live adapter handles\n+    attachments via the SDK.\n+    \"\"\"\n+    extra = getattr(pconfig, \"extra\", {}) or {}\n+    client_id = os.getenv(\"TEAMS_CLIENT_ID\") or extra.get(\"client_id\", \"\")\n+    client_secret = [redacted:secret-assignment]\"TEAMS_CLIENT_SECRET\") or extra.get(\"client_secret\", \"\")\n+    tenant_id = os.getenv(\"TEAMS_TENANT_ID\") or extra.get(\"tenant_id\", \"\")\n+    if not (client_id and client_secret and tenant_id):\n+        return {\"error\": \"Teams standalone send: TEAMS_CLIENT_ID, TEAMS_CLIENT_SECRET, and TEAMS_TENANT_ID are all required\"}\n+\n+    raw_service_url = (\n+        os.getenv(\"TEAMS_SERVICE_URL\")\n+        or extra.get(\"service_url\", \"\")\n+        or _DEFAULT_TEAMS_SERVICE_URL\n+    )\n+    service_url = _validate_teams_service_url(raw_service_url)\n+    if service_url is None:\n+        return {\"error\": (\n+            f\"Teams standalone send: TEAMS_SERVICE_URL host is not on the \"\n+            f\"Bot Framework allowlist; expected one of \"\n+            f\"{sorted(_ALLOWED_TEAMS_SERVICE_HOSTS)}\"\n+        )}\n+\n+    # Bot Framework conversation IDs are restricted to a known character\n+    # set; anything else means a tampered chat_id trying to break out of\n+    # the URL path.\n+    if not chat_id:\n+        return {\"error\": \"Teams standalone send: chat_id (conversation ID) is required\"}\n+    if not _TEAMS_CONV_ID_RE.match(chat_id):\n+        return {\"error\": \"Teams standalone send: chat_id contains characters outside the Bot Framework conversation ID set\"}\n+    if not _TEAMS_CONV_ID_RE.match(tenant_id):\n+        return {\"error\": \"Teams standalone send: TEAMS_TENANT_ID contains characters outside the expected set\"}\n+\n+    token_url = f\"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token\"\n+    activities_url = f\"{service_url}v3/conversations/{chat_id}/activities\"\n+\n+    if not AIOHTTP_AVAILABLE:\n+        return {\"error\": \"Teams standalone send: aiohttp not installed\"}\n+\n+    try:\n+        import aiohttp as _aiohttp\n+\n+        # Per-request timeouts so a slow STS endpoint cannot starve the\n+        # subsequent activity POST of its budget.\n+        per_request_timeout = _aiohttp.ClientTimeout(total=15.0)\n+        async with _aiohttp.ClientSession() as session:\n+            async with session.post(\n+                token_url,\n+                data={\n+                    \"grant_type\": \"client_credentials\",\n+                    \"client_id\": client_id,\n+                    \"client_secret\": client_secret,\n+                    \"scope\": \"https://api.botframework.com/.default\",\n+                },\n+                headers={\"Content-Type\": \"application/x-www-form-urlencoded\"},\n+                timeout=per_request_timeout,\n+            ) as token_resp:\n+                if token_resp.status >= 400:\n+                    body = await token_resp.text()\n+                    return {\"error\": f\"Teams standalone send: token request failed ({token_resp.status}): {body[:300]}\"}\n+                token_payload = await token_resp.json()\n+            access_token = [redacted:secret-assignment]\"access_token\")\n+            if not access_token:\n+                return {\"error\": \"Teams standalone send: token response missing access_token\"}\n+\n+            activity = {\n+                \"type\": \"message\",\n+                \"text\": message,\n+                \"textFormat\": \"markdown\",\n+            }\n+            async with session.post(\n+                activities_url,\n+                json=activity,\n+                headers={\n+                    \"Authorization\": f\"Bearer {access_token}\",\n+                    \"Content-Type\": \"application/json\",\n+                },\n+                timeout=per_request_timeout,\n+            ) as send_resp:\n+                if send_resp.status >= 400:\n+                    body = await send_resp.text()\n+                    return {\"error\": f\"Teams standalone send: activity post failed ({send_resp.status}): {body[:300]}\"}\n+                send_payload = await send_resp.json()\n+        return {\n+            \"success\": True,\n+            \"message_id\": send_payload.get(\"id\"),\n+        }\n+    except asyncio.CancelledError:\n+        raise\n+    except Exception as e:\n+        logger.debug(\"Teams standalone send raised\", exc_info=True)\n+        return {\"error\": f\"Teams standalone send failed: {e}\"}\n+\n+\n # Keep the old name as an alias so existing test imports don't break.\n check_teams_requirements = check_requirements\n ","@@ -985,6 +1155,10 @@ def register(ctx) -> None:\n         # jobs route to the configured Teams chat/channel without editing\n         # cron/scheduler.py's hardcoded sets.\n         cron_deliver_env_var=\"TEAMS_HOME_CHANNEL\",\n+        # Out-of-process cron delivery via Bot Framework REST.  Without\n+        # this hook, deliver=teams cron jobs fail with \"No live adapter\"\n+        # when cron runs separately from the gateway.\n+        standalone_sender_fn=_standalone_send,\n         # Auth env vars for _is_user_authorized() integration\n         allowed_users_env=\"TEAMS_ALLOWED_USERS\",\n         allow_all_env=\"TEAMS_ALLOW_ALL_USERS\","]},{"file":"tests/gateway/test_google_chat.py","hunks":["@@ -2696,3 +2696,173 @@ def test_google_chat_home_env_var_resolves(self):\n         from cron.scheduler import _resolve_home_env_var\n \n         assert _resolve_home_env_var(\"google_chat\") == \"GOOGLE_CHAT_HOME_CHANNEL\"\n+\n+\n+# ── _standalone_send (out-of-process cron delivery) ──────────────────────\n+\n+\n+class _FakeAiohttpResponse:\n+    def __init__(self, status: int, payload, text_body: str = \"\"):\n+        self.status = status\n+        self._payload = payload\n+        self._text = text_body or (str(payload) if payload is not None else \"\")\n+\n+    async def json(self):\n+        return self._payload\n+\n+    async def text(self):\n+        return self._text\n+\n+    async def __aenter__(self):\n+        return self\n+\n+    async def __aexit__(self, exc_type, exc, tb):\n+        return None\n+\n+\n+class _FakeAiohttpSession:\n+    def __init__(self, scripts):\n+        self._scripts = list(scripts)\n+        self.calls: list[tuple[str, dict]] = []\n+\n+    async def __aenter__(self):\n+        return self\n+\n+    async def __aexit__(self, exc_type, exc, tb):\n+        return None\n+\n+    def post(self, url, **kwargs):\n+        self.calls.append((url, kwargs))\n+        if not self._scripts:\n+            raise AssertionError(f\"No scripted response for POST {url}\")\n+        return self._scripts.pop(0)\n+\n+\n+def _install_fake_aiohttp(monkeypatch, session):\n+    fake_aiohttp = types.SimpleNamespace(\n+        ClientSession=lambda timeout=None: session,\n+        ClientTimeout=lambda total=None: None,\n+    )\n+    monkeypatch.setitem(sys.modules, \"aiohttp\", fake_aiohttp)\n+\n+\n+def _install_fake_google_auth_transport(monkeypatch):\n+    fake_request_module = types.SimpleNamespace(Request=lambda: object())\n+    monkeypatch.setitem(sys.modules, \"google.auth.transport\", types.SimpleNamespace(requests=fake_request_module))\n+    monkeypatch.setitem(sys.modules, \"google.auth.transport.requests\", fake_request_module)\n+\n+\n+class TestGoogleChatStandaloneSend:\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_refreshes_token_and_posts_message(\n+        self, monkeypatch, tmp_path\n+    ):\n+        sa_file = tmp_path / \"sa.json\"\n+        sa_file.write_text(json.dumps({\n+            \"type\": \"service_account\",\n+            \"client_email\": \"[redacted:email]\",\n+            \"private_key\": \"fake\",\n+            \"token_uri\": \"https://example/token\",\n+        }))\n+        monkeypatch.setenv(\"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON\", str(sa_file))\n+\n+        fake_creds = MagicMock()\n+        fake_creds.token = \"[redacted:secret-assignment]\"\n+        fake_creds.refresh = MagicMock(return_value=None)\n+\n+        original = _gc_mod.service_account.Credentials.from_service_account_info\n+        _gc_mod.service_account.Credentials.from_service_account_info = MagicMock(\n+            return_value=fake_creds\n+        )\n+        try:\n+            _install_fake_google_auth_transport(monkeypatch)\n+            send_resp = _FakeAiohttpResponse(200, {\"name\": \"spaces/AAA/messages/MMM\"})\n+            session = _FakeAiohttpSession([send_resp])\n+            _install_fake_aiohttp(monkeypatch, session)\n+\n+            result = await _gc_mod._standalone_send(\n+                PlatformConfig(enabled=True, extra={}),\n+                \"spaces/AAAA-BBBB\",\n+                \"hello cron\",\n+            )\n+        finally:\n+            _gc_mod.service_account.Credentials.from_service_account_info = original\n+\n+        assert result == {\n+            \"success\": True,\n+            \"message_id\": \"spaces/AAA/messages/MMM\",\n+        }\n+        fake_creds.refresh.assert_called_once()\n+        assert len(session.calls) == 1\n+        url, kwargs = session.calls[0]\n+        assert url == \"https://chat.googleapis.com/v1/spaces/AAAA-BBBB/messages\"\n+        assert kwargs[\"headers\"][\"Authorization\"] == \"Bearer the-token\"\n+        assert kwargs[\"json\"] == {\"text\": \"hello cron\"}\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_returns_error_on_invalid_chat_id(self, monkeypatch):\n+        monkeypatch.delenv(\"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON\", raising=False)\n+        result = await _gc_mod._standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"not-a-resource-name\",\n+            \"hi\",\n+        )\n+        assert \"error\" in result\n+        assert \"spaces/\" in result[\"error\"] or \"users/\" in result[\"error\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_propagates_api_failure(self, monkeypatch, tmp_path):\n+        sa_file = tmp_path / \"sa.json\"\n+        sa_file.write_text(json.dumps({\n+            \"type\": \"service_account\",\n+            \"client_email\": \"[redacted:email]\",\n+            \"private_key\": \"fake\",\n+            \"token_uri\": \"https://example/token\",\n+        }))\n+        monkeypatch.setenv(\"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON\", str(sa_file))\n+\n+        fake_creds = MagicMock()\n+        fake_creds.token = \"[redacted:secret-assignment]\"\n+        fake_creds.refresh = MagicMock(return_value=None)\n+\n+        original = _gc_mod.service_account.Credentials.from_service_account_info\n+        _gc_mod.service_account.Credentials.from_service_account_info = MagicMock(\n+            return_value=fake_creds\n+        )\n+        try:\n+            _install_fake_google_auth_transport(monkeypatch)\n+            send_resp = _FakeAiohttpResponse(\n+                403,\n+                {\"error\": {\"code\": 403, \"message\": \"forbidden\"}},\n+                text_body='{\"error\":{\"code\":403,\"message\":\"forbidden\"}}',\n+            )\n+            session = _FakeAiohttpSession([send_resp])\n+            _install_fake_aiohttp(monkeypatch, session)\n+\n+            result = await _gc_mod._standalone_send(\n+                PlatformConfig(enabled=True, extra={}),\n+                \"spaces/AAAA-BBBB\",\n+                \"hi\",\n+            )\n+        finally:\n+            _gc_mod.service_account.Credentials.from_service_account_info = original\n+\n+        assert \"error\" in result\n+        assert \"403\" in result[\"error\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_rejects_chat_id_with_path_traversal(self, monkeypatch):\n+        monkeypatch.delenv(\"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON\", raising=False)\n+\n+        # Attempt to inject extra path segments after the prefix passes the\n+        # startswith check.  The strict regex must reject this.\n+        result = await _gc_mod._standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"spaces/AAAA/messages?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD\",\n+            \"hi\",\n+        )\n+\n+        assert \"error\" in result\n+        # The error names the expected resource shape so plugin authors can self-correct\n+        assert \"spaces/\" in result[\"error\"] or \"users/\" in result[\"error\"]"]},{"file":"tests/gateway/test_irc_adapter.py","hunks":["@@ -20,6 +20,7 @@\n check_requirements = _irc_mod.check_requirements\n validate_config = _irc_mod.validate_config\n register = _irc_mod.register\n+_standalone_send = _irc_mod._standalone_send\n \n \n class TestIRCProtocolHelpers:","@@ -500,3 +501,224 @@ def test_register_adds_to_registry(self, monkeypatch):\n         ctx.register_platform.assert_called_once()\n         call_kwargs = ctx.register_platform.call_args\n         assert call_kwargs[1][\"name\"] == \"irc\" or call_kwargs[0][0] == \"irc\" if call_kwargs[0] else call_kwargs[1][\"name\"] == \"irc\"\n+\n+\n+# ── _standalone_send (out-of-process cron delivery) ──────────────────────\n+\n+\n+class _FakeIRCConnection:\n+    \"\"\"A scripted reader/writer pair used to simulate an IRC server.\n+\n+    Construct with the lines the server should respond with (already\n+    framed by ``\\\\r\\\\n``).  Captures every line written by the client so\n+    tests can assert NICK/USER/PRIVMSG/QUIT order.\n+    \"\"\"\n+\n+    def __init__(self, scripted_lines):\n+        self.writes: list[bytes] = []\n+        self._closed = False\n+        self._scripted = list(scripted_lines)\n+        self._buffer = b\"\"\n+\n+    # writer side ────────────────────────────────────────────────────\n+    def write(self, data: bytes) -> None:\n+        self.writes.append(data)\n+\n+    async def drain(self) -> None:\n+        return None\n+\n+    def close(self) -> None:\n+        self._closed = True\n+\n+    async def wait_closed(self) -> None:\n+        return None\n+\n+    def is_closing(self) -> bool:\n+        return self._closed\n+\n+    # reader side ────────────────────────────────────────────────────\n+    async def readuntil(self, separator: bytes = b\"\\r\\n\") -> bytes:\n+        if not self._scripted:\n+            raise asyncio.IncompleteReadError(b\"\", None)\n+        line = self._scripted.pop(0)\n+        if not line.endswith(b\"\\r\\n\"):\n+            line = line + b\"\\r\\n\"\n+        return line\n+\n+    async def read(self, n: int = -1) -> bytes:\n+        return b\"\"\n+\n+\n+class TestIRCStandaloneSend:\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_completes_handshake_and_sends_privmsg(self, monkeypatch):\n+        from gateway.config import PlatformConfig\n+\n+        monkeypatch.setenv(\"IRC_SERVER\", \"irc.test.net\")\n+        monkeypatch.setenv(\"IRC_CHANNEL\", \"#cron\")\n+        monkeypatch.setenv(\"IRC_NICKNAME\", \"hermesbot\")\n+        monkeypatch.setenv(\"IRC_USE_TLS\", \"false\")\n+\n+        # Server greets us with 001 RPL_WELCOME, then nothing for QUIT drain.\n+        conn = _FakeIRCConnection([b\":server 001 hermesbot-cron :Welcome\"])\n+\n+        async def _fake_open(host, port, **kwargs):\n+            return conn, conn  # reader and writer share the same fake\n+\n+        monkeypatch.setattr(_irc_mod.asyncio, \"open_connection\", _fake_open)\n+\n+        result = await _standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"#cron\",\n+            \"hello from cron\",\n+        )\n+\n+        assert result[\"success\"] is True\n+        assert \"message_id\" in result\n+\n+        sent_lines = b\"\".join(conn.writes).decode(\"utf-8\").splitlines()\n+        # NICK uses the cron-suffixed identity to avoid colliding with the\n+        # long-running gateway adapter that may already hold the nickname.\n+        assert any(line.startswith(\"NICK hermesbot-cron\") for line in sent_lines)\n+        assert any(line.startswith(\"USER hermesbot-cron 0 * :Hermes Agent (cron)\")\n+                   for line in sent_lines)\n+        assert any(line == \"PRIVMSG #cron :hello from cron\" for line in sent_lines)\n+        assert any(line.startswith(\"QUIT \") for line in sent_lines)\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_returns_error_when_unconfigured(self, monkeypatch):\n+        from gateway.config import PlatformConfig\n+\n+        for var in (\"IRC_SERVER\", \"IRC_CHANNEL\"):\n+            monkeypatch.delenv(var, raising=False)\n+\n+        result = await _standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"\",\n+            \"hi\",\n+        )\n+\n+        assert \"error\" in result\n+        assert \"IRC_SERVER\" in result[\"error\"] or \"IRC_CHANNEL\" in result[\"error\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_returns_error_on_registration_timeout(self, monkeypatch):\n+        from gateway.config import PlatformConfig\n+\n+        monkeypatch.setenv(\"IRC_SERVER\", \"irc.test.net\")\n+        monkeypatch.setenv(\"IRC_CHANNEL\", \"#cron\")\n+        monkeypatch.setenv(\"IRC_NICKNAME\", \"hermesbot\")\n+        monkeypatch.setenv(\"IRC_USE_TLS\", \"false\")\n+\n+        # No 001 response: the readuntil call returns IncompleteReadError so\n+        # the registration loop times out via the asyncio wait_for inside.\n+        conn = _FakeIRCConnection([])\n+\n+        async def _fake_open(host, port, **kwargs):\n+            return conn, conn\n+\n+        monkeypatch.setattr(_irc_mod.asyncio, \"open_connection\", _fake_open)\n+\n+        # Patch wait_for to raise TimeoutError immediately so the test is fast\n+        async def _fast_timeout(coro, timeout):\n+            try:\n+                return await coro\n+            except asyncio.IncompleteReadError:\n+                raise asyncio.TimeoutError()\n+\n+        monkeypatch.setattr(_irc_mod.asyncio, \"wait_for\", _fast_timeout)\n+\n+        result = await _standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"#cron\",\n+            \"hi\",\n+        )\n+\n+        assert \"error\" in result\n+        assert \"registration\" in result[\"error\"].lower() or \"timeout\" in result[\"error\"].lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_rejects_crlf_in_chat_id(self, monkeypatch):\n+        from gateway.config import PlatformConfig\n+\n+        monkeypatch.setenv(\"IRC_SERVER\", \"irc.test.net\")\n+        monkeypatch.setenv(\"IRC_CHANNEL\", \"#cron\")\n+        monkeypatch.setenv(\"IRC_NICKNAME\", \"hermesbot\")\n+        monkeypatch.setenv(\"IRC_USE_TLS\", \"false\")\n+\n+        # Attempt to inject a second IRC command via CRLF in chat_id\n+        result = await _standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"#cron\\r\\nKICK #cron hermesbot\",\n+            \"hi\",\n+        )\n+\n+        assert \"error\" in result\n+        assert \"illegal IRC characters\" in result[\"error\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_strips_crlf_from_message_body(self, monkeypatch):\n+        from gateway.config import PlatformConfig\n+\n+        monkeypatch.setenv(\"IRC_SERVER\", \"irc.test.net\")\n+        monkeypatch.setenv(\"IRC_CHANNEL\", \"#cron\")\n+        monkeypatch.setenv(\"IRC_NICKNAME\", \"hermesbot\")\n+        monkeypatch.setenv(\"IRC_USE_TLS\", \"false\")\n+\n+        conn = _FakeIRCConnection([b\":server 001 hermesbot-cron :Welcome\"])\n+\n+        async def _fake_open(host, port, **kwargs):\n+            return conn, conn\n+\n+        monkeypatch.setattr(_irc_mod.asyncio, \"open_connection\", _fake_open)\n+\n+        # A bare \\r in message content tries to inject a NICK command.\n+        # Our control-char stripper must blank \\r so the line stays one PRIVMSG.\n+        result = await _standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"#cron\",\n+            \"hello\\rNICK eviltwin\",\n+        )\n+\n+        sent_lines = b\"\".join(conn.writes).decode(\"utf-8\").splitlines()\n+        # No injected NICK command after the legitimate registration NICK\n+        nick_lines = [line for line in sent_lines if line.startswith(\"NICK \")]\n+        # Only the original registration NICK should be present (no injected one)\n+        assert all(line.startswith(\"NICK hermesbot-cron\") for line in nick_lines)\n+        # The PRIVMSG should contain \"hello NICK eviltwin\" as one line (with \\r blanked)\n+        assert any(\"PRIVMSG #cron :hello NICK eviltwin\" in line for line in sent_lines)\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_joins_channel_before_privmsg(self, monkeypatch):\n+        from gateway.config import PlatformConfig\n+\n+        monkeypatch.setenv(\"IRC_SERVER\", \"irc.test.net\")\n+        monkeypatch.setenv(\"IRC_CHANNEL\", \"#cron\")\n+        monkeypatch.setenv(\"IRC_NICKNAME\", \"hermesbot\")\n+        monkeypatch.setenv(\"IRC_USE_TLS\", \"false\")\n+\n+        # Register, then accept JOIN with 366 RPL_ENDOFNAMES, then PRIVMSG.\n+        conn = _FakeIRCConnection([\n+            b\":server 001 hermesbot-cron :Welcome\",\n+            b\":server 366 hermesbot-cron #cron :End of /NAMES list.\",\n+        ])\n+\n+        async def _fake_open(host, port, **kwargs):\n+            return conn, conn\n+\n+        monkeypatch.setattr(_irc_mod.asyncio, \"open_connection\", _fake_open)\n+\n+        result = await _standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"#cron\",\n+            \"hello\",\n+        )\n+\n+        assert result[\"success\"] is True\n+        sent_lines = b\"\".join(conn.writes).decode(\"utf-8\").splitlines()\n+        join_idx = next((i for i, line in enumerate(sent_lines) if line.startswith(\"JOIN #cron\")), None)\n+        privmsg_idx = next((i for i, line in enumerate(sent_lines) if line.startswith(\"PRIVMSG #cron\")), None)\n+        assert join_idx is not None, \"JOIN must be sent for channel targets\"\n+        assert privmsg_idx is not None\n+        assert join_idx < privmsg_idx, \"JOIN must precede PRIVMSG\""]},{"file":"tests/gateway/test_teams.py","hunks":["@@ -703,3 +703,177 @@ async def test_deduplication(self):\n         await adapter._on_message(ctx)\n \n         assert adapter.handle_message.await_count == 1\n+\n+\n+# ── _standalone_send (out-of-process cron delivery) ──────────────────────\n+\n+\n+class _FakeAiohttpResponse:\n+    def __init__(self, status: int, payload, text_body: str = \"\"):\n+        self.status = status\n+        self._payload = payload\n+        self._text = text_body or (str(payload) if payload is not None else \"\")\n+\n+    async def json(self):\n+        return self._payload\n+\n+    async def text(self):\n+        return self._text\n+\n+    async def __aenter__(self):\n+        return self\n+\n+    async def __aexit__(self, exc_type, exc, tb):\n+        return None\n+\n+\n+class _FakeAiohttpSession:\n+    \"\"\"Scripted aiohttp.ClientSession with a queue of responses so tests\n+    can assert calls in order.\"\"\"\n+\n+    def __init__(self, scripts):\n+        self._scripts = list(scripts)\n+        self.calls: list[tuple[str, dict]] = []\n+\n+    async def __aenter__(self):\n+        return self\n+\n+    async def __aexit__(self, exc_type, exc, tb):\n+        return None\n+\n+    def post(self, url, **kwargs):\n+        self.calls.append((url, kwargs))\n+        if not self._scripts:\n+            raise AssertionError(f\"No scripted response for POST {url}\")\n+        return self._scripts.pop(0)\n+\n+\n+def _install_fake_aiohttp(monkeypatch, session):\n+    \"\"\"Replace ``aiohttp`` in ``sys.modules`` so ``import aiohttp as _aiohttp``\n+    inside ``_standalone_send`` picks up our fake.\"\"\"\n+    fake_aiohttp = types.SimpleNamespace(\n+        ClientSession=lambda timeout=None: session,\n+        ClientTimeout=lambda total=None: None,\n+    )\n+    monkeypatch.setitem(sys.modules, \"aiohttp\", fake_aiohttp)\n+\n+\n+class TestTeamsStandaloneSend:\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_acquires_token_and_posts_activity(self, monkeypatch):\n+        monkeypatch.setenv(\"TEAMS_CLIENT_ID\", \"client-id\")\n+        monkeypatch.setenv(\"TEAMS_CLIENT_SECRET\", \"secret\")\n+        monkeypatch.setenv(\"TEAMS_TENANT_ID\", \"tenant\")\n+        monkeypatch.delenv(\"TEAMS_SERVICE_URL\", raising=False)\n+\n+        token_resp = [redacted:secret-assignment] {\"access_token\": \"the-token\"})\n+        activity_resp = _FakeAiohttpResponse(200, {\"id\": \"msg-99\"})\n+        session = _FakeAiohttpSession([token_resp, activity_resp])\n+        _install_fake_aiohttp(monkeypatch, session)\n+\n+        result = await _teams_mod._standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"19:[redacted:email]\",\n+            \"hello cron\",\n+        )\n+\n+        assert result == {\"success\": True, \"message_id\": \"msg-99\"}\n+        assert len(session.calls) == 2\n+\n+        token_url, token_kwargs = [redacted:secret-assignment]\n+        assert \"login.microsoftonline.com/tenant/oauth2/v2.0/token\" in token_url\n+        assert token_kwargs[\"data\"][\"client_id\"] == \"client-id\"\n+        assert token_kwargs[\"data\"][\"client_secret\"] == \"secret\"\n+        assert token_kwargs[\"data\"][\"scope\"] == \"https://api.botframework.com/.default\"\n+\n+        activity_url, activity_kwargs = session.calls[1]\n+        # Default service URL when TEAMS_SERVICE_URL is unset\n+        assert \"smba.trafficmanager.net\" in activity_url\n+        assert \"/v3/conversations/19:[redacted:email]/activities\" in activity_url\n+        assert activity_kwargs[\"headers\"][\"Authorization\"] == \"Bearer the-token\"\n+        assert activity_kwargs[\"json\"][\"text\"] == \"hello cron\"\n+        assert activity_kwargs[\"json\"][\"type\"] == \"message\"\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_returns_error_when_unconfigured(self, monkeypatch):\n+        for var in (\"TEAMS_CLIENT_ID\", \"TEAMS_CLIENT_SECRET\", \"TEAMS_TENANT_ID\"):\n+            monkeypatch.delenv(var, raising=False)\n+\n+        result = await _teams_mod._standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"19:[redacted:email]\",\n+            \"hi\",\n+        )\n+\n+        assert \"error\" in result\n+        assert \"TEAMS_CLIENT_ID\" in result[\"error\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_propagates_token_failure(self, monkeypatch):\n+        monkeypatch.setenv(\"TEAMS_CLIENT_ID\", \"client-id\")\n+        monkeypatch.setenv(\"TEAMS_CLIENT_SECRET\", \"secret\")\n+        monkeypatch.setenv(\"TEAMS_TENANT_ID\", \"tenant\")\n+\n+        token_resp = [redacted:secret-assignment]\n+            401,\n+            {\"error\": \"unauthorized_client\"},\n+            text_body='{\"error\":\"unauthorized_client\"}',\n+        )\n+        session = _FakeAiohttpSession([token_resp])\n+        _install_fake_aiohttp(monkeypatch, session)\n+\n+        result = await _teams_mod._standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"19:[redacted:email]\",\n+            \"hi\",\n+        )\n+\n+        assert \"error\" in result\n+        assert \"401\" in result[\"error\"]\n+        assert \"token\" in result[\"error\"].lower()\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_rejects_off_allowlist_service_url(self, monkeypatch):\n+        monkeypatch.setenv(\"TEAMS_CLIENT_ID\", \"client-id\")\n+        monkeypatch.setenv(\"TEAMS_CLIENT_SECRET\", \"secret\")\n+        monkeypatch.setenv(\"TEAMS_TENANT_ID\", \"tenant\")\n+        # SSRF attempt: point us at an attacker-controlled host\n+        monkeypatch.setenv(\"TEAMS_SERVICE_URL\", \"https://attacker.example.com/teams/\")\n+\n+        # If the allowlist check fails to fire, the fake session will assert\n+        # because no scripts are queued; a passing test means we returned\n+        # before any HTTP call.\n+        session = _FakeAiohttpSession([])\n+        _install_fake_aiohttp(monkeypatch, session)\n+\n+        result = await _teams_mod._standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"19:[redacted:email]\",\n+            \"hi\",\n+        )\n+\n+        assert \"error\" in result\n+        assert \"allowlist\" in result[\"error\"].lower()\n+        assert len(session.calls) == 0, \"must not call any HTTP endpoint with a tampered service URL\"\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_send_rejects_chat_id_with_path_traversal(self, monkeypatch):\n+        monkeypatch.setenv(\"TEAMS_CLIENT_ID\", \"client-id\")\n+        monkeypatch.setenv(\"TEAMS_CLIENT_SECRET\", \"secret\")\n+        monkeypatch.setenv(\"TEAMS_TENANT_ID\", \"tenant\")\n+        monkeypatch.delenv(\"TEAMS_SERVICE_URL\", raising=False)\n+\n+        session = _FakeAiohttpSession([])\n+        _install_fake_aiohttp(monkeypatch, session)\n+\n+        # Attempt to break out of /v3/conversations/<id>/activities via a `/`\n+        result = await _teams_mod._standalone_send(\n+            PlatformConfig(enabled=True, extra={}),\n+            \"19:abc/activities/19:[redacted:email]\",\n+            \"hi\",\n+        )\n+\n+        assert \"error\" in result\n+        assert \"Bot Framework conversation ID\" in result[\"error\"]\n+        assert len(session.calls) == 0"]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -2052,3 +2052,180 @@ def test_skipped_missing_files_reported_in_warnings(self, tmp_path, monkeypatch)\n         # Only the existing file made it into the RPC\n         params = fake.calls[0][\"payload\"][\"params\"]\n         assert len(params[\"attachments\"]) == 1\n+\n+\n+# ── _send_via_adapter standalone fallback ────────────────────────────────\n+\n+\n+class _FakePlatform:\n+    \"\"\"Stand-in for the gateway.config.Platform enum.  Holds the .value\n+    attribute consulted by ``_send_via_adapter`` for registry lookups.\"\"\"\n+\n+    def __init__(self, value):\n+        self.value = value\n+\n+\n+class TestSendViaAdapterStandaloneFallback:\n+    \"\"\"Coverage for the out-of-process plugin-platform send path.\n+\n+    When the gateway runner is not in this process (e.g. ``hermes cron``\n+    runs separately from ``hermes gateway``), ``_send_via_adapter`` should\n+    fall through to the plugin's ``standalone_sender_fn`` registered on\n+    its ``PlatformEntry``.  Without the hook, the existing error string\n+    is returned (with a more helpful tail).\n+    \"\"\"\n+\n+    @staticmethod\n+    def _make_entry(send_fn):\n+        from gateway.platform_registry import PlatformEntry\n+\n+        return PlatformEntry(\n+            name=\"fakeplatform\",\n+            label=\"Fake\",\n+            adapter_factory=lambda cfg: None,\n+            check_fn=lambda: True,\n+            standalone_sender_fn=send_fn,\n+        )\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_sender_fn_called_when_no_adapter(self, monkeypatch):\n+        \"\"\"Registry has hook, runner ref returns None: the hook is awaited.\"\"\"\n+        from tools.send_message_tool import _send_via_adapter\n+        from gateway.platform_registry import platform_registry\n+\n+        recorded = {}\n+\n+        async def fake_send(pconfig, chat_id, message, **kwargs):\n+            recorded[\"pconfig\"] = pconfig\n+            recorded[\"chat_id\"] = chat_id\n+            recorded[\"message\"] = message\n+            recorded[\"kwargs\"] = kwargs\n+            return {\"success\": True, \"message_id\": \"msg-42\"}\n+\n+        platform_registry.register(self._make_entry(fake_send))\n+        try:\n+            monkeypatch.setattr(\"gateway.run._gateway_runner_ref\", lambda: None)\n+\n+            pconfig = SimpleNamespace(extra={})\n+            result = await _send_via_adapter(\n+                _FakePlatform(\"fakeplatform\"),\n+                pconfig,\n+                \"room/123\",\n+                \"hello cron\",\n+            )\n+        finally:\n+            platform_registry.unregister(\"fakeplatform\")\n+\n+        assert result == {\"success\": True, \"message_id\": \"msg-42\"}\n+        assert recorded[\"chat_id\"] == \"room/123\"\n+        assert recorded[\"message\"] == \"hello cron\"\n+        assert recorded[\"pconfig\"] is pconfig\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_sender_fn_kwargs_forwarded(self, monkeypatch):\n+        \"\"\"thread_id, media_files, and force_document all reach the hook.\"\"\"\n+        from tools.send_message_tool import _send_via_adapter\n+        from gateway.platform_registry import platform_registry\n+\n+        recorded = {}\n+\n+        async def fake_send(pconfig, chat_id, message, *, thread_id=None,\n+                            media_files=None, force_document=False):\n+            recorded[\"thread_id\"] = thread_id\n+            recorded[\"media_files\"] = media_files\n+            recorded[\"force_document\"] = force_document\n+            return {\"success\": True, \"message_id\": \"x\"}\n+\n+        platform_registry.register(self._make_entry(fake_send))\n+        try:\n+            monkeypatch.setattr(\"gateway.run._gateway_runner_ref\", lambda: None)\n+\n+            await _send_via_adapter(\n+                _FakePlatform(\"fakeplatform\"),\n+                SimpleNamespace(extra={}),\n+                \"chat-1\",\n+                \"hi\",\n+                thread_id=\"thread-7\",\n+                media_files=[\"/tmp/a.png\"],\n+                force_document=True,\n+            )\n+        finally:\n+            platform_registry.unregister(\"fakeplatform\")\n+\n+        assert recorded[\"thread_id\"] == \"thread-7\"\n+        assert recorded[\"media_files\"] == [\"/tmp/a.png\"]\n+        assert recorded[\"force_document\"] is True\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_sender_fn_absent_returns_helpful_error(self, monkeypatch):\n+        \"\"\"Registry entry has no hook: the fall-through error explains both\n+        options (gateway-running and standalone hook).\"\"\"\n+        from tools.send_message_tool import _send_via_adapter\n+        from gateway.platform_registry import platform_registry\n+\n+        platform_registry.register(self._make_entry(None))\n+        try:\n+            monkeypatch.setattr(\"gateway.run._gateway_runner_ref\", lambda: None)\n+\n+            result = await _send_via_adapter(\n+                _FakePlatform(\"fakeplatform\"),\n+                SimpleNamespace(extra={}),\n+                \"chat-1\",\n+                \"hi\",\n+            )\n+        finally:\n+            platform_registry.unregister(\"fakeplatform\")\n+\n+        assert \"error\" in result\n+        assert \"fakeplatform\" in result[\"error\"]\n+        assert \"standalone_sender_fn\" in result[\"error\"]\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_sender_fn_raises_is_caught_and_formatted(self, monkeypatch):\n+        \"\"\"Hook raises: error dict has 'Plugin standalone send failed: ...'\"\"\"\n+        from tools.send_message_tool import _send_via_adapter\n+        from gateway.platform_registry import platform_registry\n+\n+        async def boom(pconfig, chat_id, message, **kwargs):\n+            raise ValueError(\"boom!\")\n+\n+        platform_registry.register(self._make_entry(boom))\n+        try:\n+            monkeypatch.setattr(\"gateway.run._gateway_runner_ref\", lambda: None)\n+\n+            result = await _send_via_adapter(\n+                _FakePlatform(\"fakeplatform\"),\n+                SimpleNamespace(extra={}),\n+                \"chat-1\",\n+                \"hi\",\n+            )\n+        finally:\n+            platform_registry.unregister(\"fakeplatform\")\n+\n+        assert result == {\"error\": \"Plugin standalone send failed: boom!\"}\n+\n+    @pytest.mark.asyncio\n+    async def test_standalone_sender_fn_return_shape_passed_through(self, monkeypatch):\n+        \"\"\"Hook returns success dict: passed through unchanged.\"\"\"\n+        from tools.send_message_tool import _send_via_adapter\n+        from gateway.platform_registry import platform_registry\n+\n+        async def fake_send(pconfig, chat_id, message, **kwargs):\n+            return {\"success\": True, \"message_id\": \"abc-123\", \"extra_field\": \"preserved\"}\n+\n+        platform_registry.register(self._make_entry(fake_send))\n+        try:\n+            monkeypatch.setattr(\"gateway.run._gateway_runner_ref\", lambda: None)\n+\n+            result = await _send_via_adapter(\n+                _FakePlatform(\"fakeplatform\"),\n+                SimpleNamespace(extra={}),\n+                \"chat-1\",\n+                \"hi\",\n+            )\n+        finally:\n+            platform_registry.unregister(\"fakeplatform\")\n+\n+        assert result[\"success\"] is True\n+        assert result[\"message_id\"] == \"abc-123\"\n+        assert result[\"extra_field\"] == \"preserved\""]},{"file":"tools/send_message_tool.py","hunks":["@@ -423,25 +423,92 @@ def _maybe_skip_cron_duplicate_send(platform_name: str, chat_id: str, thread_id:\n     }\n \n \n-async def _send_via_adapter(platform, pconfig, chat_id, chunk):\n-    \"\"\"Send a message via a live gateway adapter (for plugin platforms).\n-\n-    Falls back to error if no adapter is connected for this platform.\n+async def _send_via_adapter(\n+    platform,\n+    pconfig,\n+    chat_id,\n+    chunk,\n+    *,\n+    thread_id=None,\n+    media_files=None,\n+    force_document=False,\n+):\n+    \"\"\"Send a message via a live gateway adapter, with a standalone fallback\n+    for out-of-process callers (e.g. cron running separately from the gateway).\n+\n+    Order of attempts:\n+      1. Live in-process adapter via ``_gateway_runner_ref()`` (the path that\n+         existed before this change).\n+      2. The plugin's ``standalone_sender_fn`` registered on its\n+         ``PlatformEntry`` (used when the gateway is not in this process, so\n+         the runner weakref is ``None``).\n+      3. A descriptive error explaining both options.\n     \"\"\"\n+    runner = None\n     try:\n         from gateway.run import _gateway_runner_ref\n         runner = _gateway_runner_ref()\n-        if runner:\n+    except Exception:\n+        runner = None\n+\n+    if runner is not None:\n+        try:\n             adapter = runner.adapters.get(platform)\n-            if adapter:\n-                from gateway.platforms.base import SendResult\n+        except Exception:\n+            adapter = None\n+        if adapter is not None:\n+            try:\n                 result = await adapter.send(chat_id=chat_id, content=chunk)\n-                if result.success:\n-                    return {\"success\": True, \"message_id\": result.message_id}\n-                return {\"error\": f\"Adapter send failed: {result.error}\"}\n-    except Exception as e:\n-        return {\"error\": f\"Plugin platform send failed: {e}\"}\n-    return {\"error\": f\"No live adapter for platform '{platform.value}'. Is the gateway running with this platform connected?\"}\n+            except asyncio.CancelledError:\n+                raise\n+            except Exception as e:\n+                return {\"error\": f\"Plugin platform send failed: {e}\"}\n+            if result.success:\n+                return {\"success\": True, \"message_id\": result.message_id}\n+            return {\"error\": f\"Adapter send failed: {result.error}\"}\n+\n+    platform_name = platform.value if hasattr(platform, \"value\") else str(platform)\n+    entry = None\n+    try:\n+        from gateway.platform_registry import platform_registry\n+        entry = platform_registry.get(platform_name)\n+    except Exception:\n+        entry = None\n+\n+    if entry is not None and entry.standalone_sender_fn is not None:\n+        try:\n+            result = await entry.standalone_sender_fn(\n+                pconfig,\n+                chat_id,\n+                chunk,\n+                thread_id=thread_id,\n+                media_files=media_files,\n+                force_document=force_document,\n+            )\n+        except asyncio.CancelledError:\n+            raise\n+        except Exception as e:\n+            logger.debug(\"Plugin standalone send for %s raised\", platform_name, exc_info=True)\n+            return {\"error\": f\"Plugin standalone send failed: {e}\"}\n+\n+        if isinstance(result, dict) and (result.get(\"success\") or result.get(\"error\")):\n+            return result\n+        return {\n+            \"error\": (\n+                f\"Plugin standalone send for '{platform_name}' returned an \"\n+                f\"invalid result: expected a dict with 'success' or 'error' \"\n+                f\"keys, got {type(result).__name__}\"\n+            )\n+        }\n+\n+    return {\n+        \"error\": (\n+            f\"No live adapter for platform '{platform_name}'. Is the gateway \"\n+            f\"running with this platform connected? For out-of-process delivery \"\n+            f\"(e.g. cron in a separate process), the platform plugin must \"\n+            f\"register a standalone_sender_fn on its PlatformEntry.\"\n+        )\n+    }\n \n \n async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None, force_document=False):","@@ -660,9 +727,17 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n         elif platform == Platform.YUANBAO:\n             result = await _send_yuanbao(chat_id, chunk)\n         else:\n-            # Plugin platform — route through the gateway's live adapter\n-            # if available, otherwise report the error.\n-            result = await _send_via_adapter(platform, pconfig, chat_id, chunk)\n+            # Plugin platform: route through the gateway's live adapter if\n+            # available, otherwise the plugin's standalone_sender_fn.\n+            result = await _send_via_adapter(\n+                platform,\n+                pconfig,\n+                chat_id,\n+                chunk,\n+                thread_id=thread_id,\n+                media_files=media_files,\n+                force_document=force_document,\n+            )\n \n         if isinstance(result, dict) and result.get(\"error\"):\n             return result"]},{"file":"website/docs/developer-guide/adding-platform-adapters.md","hunks":["@@ -253,6 +253,37 @@ ctx.register_platform(\n \n The scheduler reads this env var when resolving the home target for `deliver=my_platform` jobs, and also treats the platform as a valid cron target in `_KNOWN_DELIVERY_PLATFORMS`-style checks. If your `env_enablement_fn` seeds a `home_channel` dict (see above), that takes precedence — `cron_deliver_env_var` is the fallback for cron jobs that run before env seeding.\n \n+### Out-of-process cron delivery\n+\n+`cron_deliver_env_var` makes your platform a recognized `deliver=` target. To make the actual send succeed when the cron job runs in a separate process from the gateway (i.e., `hermes cron run` separate from `hermes gateway`), register a `standalone_sender_fn`:\n+\n+```python\n+async def _standalone_send(\n+    pconfig,\n+    chat_id,\n+    message,\n+    *,\n+    thread_id=None,\n+    media_files=None,\n+    force_document=False,\n+):\n+    \"\"\"Open an ephemeral connection / acquire a fresh token, send, and close.\"\"\"\n+    # ... open connection, send message, return result ...\n+    return {\"success\": True, \"message_id\": \"...\"}\n+    # or {\"error\": \"...\"}\n+\n+ctx.register_platform(\n+    name=\"my_platform\",\n+    ...\n+    cron_deliver_env_var=\"MY_PLATFORM_HOME_CHANNEL\",\n+    standalone_sender_fn=_standalone_send,\n+)\n+```\n+\n+Why this hook is necessary: built-in platforms (Telegram, Discord, Slack, etc.) ship direct REST helpers in `tools/send_message_tool.py` so cron can deliver without holding the gateway in the same process. Plugin platforms historically depended on `_gateway_runner_ref()`, which returns `None` outside the gateway process, so without `standalone_sender_fn` the cron-side send fails with `No live adapter for platform '<name>'`.\n+\n+The function receives the same `pconfig` and `chat_id` that the live adapter would, plus optional `thread_id`, `media_files`, and `force_document` keyword arguments. Returning `{\"success\": True, \"message_id\": ...}` is treated as a successful delivery; returning `{\"error\": \"...\"}` surfaces the message in cron's `delivery_errors`. Exceptions raised inside the function are caught by the dispatcher and reported as `Plugin standalone send failed: <reason>`. Reference implementations live in `plugins/platforms/{irc,teams,google_chat}/adapter.py`.\n+\n ## Surfacing Env Vars in `hermes config`\n \n `hermes_cli/config.py` scans `plugins/platforms/*/plugin.yaml` at import time and auto-populates `OPTIONAL_ENV_VARS` from `requires_env` and (optional) `optional_env` blocks. Use the rich-dict form to contribute proper descriptions, prompts, password flags, and URLs — the CLI setup UI picks them up for free.\n"]}]},{"kind":"pr","number":64913,"title":"feat(gateway): plugin platform hints and standalone sender","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e464cde58fffe776366bc1ae0498e081df3bac32","number":0},{"kind":"commit","value":"93e25ceb1326770b369b8c4151cd3b9c3cdc0688","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}