# NousResearch/hermes-agent · SimpleX Chat gateway adapter feature requests

## Open now

- Nothing open on GitHub.

These issues and pull requests all concern adding a SimpleX Chat platform adapter or plugin to the gateway. They represent related but potentially overlapping efforts toward the same integration goal.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #26232. Verify it addresses your case.
- **Recommended:** read PR #26232 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-26232`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #2557: \[Feature\]: Add SimpleX Chat, Chat Adapter

## Prior & competing PRs

- **#26232: feat(gateway): add SimpleX Chat platform plugin (salvages #2558)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `plugins/platforms/simplex/__init__.py`, `plugins/platforms/simplex/adapter.py`, `plugins/platforms/simplex/plugin.yaml`, `scripts/release.py`, `tests/gateway/test_simplex_plugin.py`, `website/docs/user-guide/messaging/simplex.md`, `website/sidebars.ts`
  - fix hunks:
  - `plugins/platforms/simplex/__init__.py`

    ```diff
    @@ -0,0 +1,3 @@
    +from .adapter import register
    +
    +__all__ = ["register"]
    ```
  - `plugins/platforms/simplex/adapter.py`

    ```diff
    @@ -0,0 +1,746 @@
    +"""SimpleX Chat platform adapter (Hermes plugin).
    +
    +Connects to a simplex-chat daemon running in WebSocket mode.
    +Inbound messages arrive via a persistent WebSocket connection.
    +Outbound messages use the same WebSocket with JSON commands.
    +
    +This adapter ships as a Hermes platform plugin under
    +``plugins/platforms/simplex/``. The Hermes plugin loader scans the
    +directory at startup, calls ``register(ctx)``, and the platform
    +becomes available to ``gateway/run.py`` and ``tools/send_message_tool``
    +through the registry — no edits to core files are required.
    +
    +SimpleX chat daemon setup:
    +    simplex-chat -p 5225          # start daemon on port 5225
    +    # or via Docker:
    +    # docker run -p 5225:5225 simplexchat/simplex-chat-cli -p 5225
    +
    +Required environment variables:
    +    SIMPLEX_WS_URL             WebSocket URL of the daemon
    +                               (default: ws://127.0.0.1:5225)
    +
    +Optional environment variables:
    +    SIMPLEX_ALLOWED_USERS      Comma-separated contact IDs (allowlist)
    +    SIMPLEX_ALLOW_ALL_USERS    Set 'true' to allow all contacts
    +    SIMPLEX_HOME_CHANNEL       Default contact/group ID for cron delivery
    +    SIMPLEX_HOME_CHANNEL_NAME  Human label for the home channel
    +
    +The ``websockets`` Python package is imported lazily — the plugin is
    +discoverable and `hermes setup` can describe it even when websockets is
    +not installed. ``check_requirements()`` returns False until the package
    +is present, so the gateway will not attempt to instantiate the adapter.
    +"""
    +
    +import asyncio
    +import json
    +import logging
    +import os
    +import random
    +import time
    +from datetime import datetime, timezone
    +from typing import Any, Dict, List, Optional
    +
    +# Lazy import: BasePlatformAdapter and friends live in the main repo.
    +# Imported at module top because they're stdlib-only inside Hermes — no
    +# external dependency that would block the plugin from loading.
    +from gateway.config import Platform, PlatformConfig
    +from gateway.platforms.base import (
    +    BasePlatformAdapter,
    +    MessageEvent,
    +    MessageType,
    +    SendResult,
    +    cache_image_from_bytes,
    +    cache_audio_from_bytes,
    +    cache_document_from_bytes,
    +)
    +
    +logger = logging.getLogger(__name__)
    +
    +# ---------------------------------------------------------------------------
    +# Constants
    +# ---------------------------------------------------------------------------
    +MAX_MESSAGE_LENGTH = 16_000  # SimpleX has no hard limit; keep chunking sane
    +TYPING_INTERVAL = 10.0
    +WS_RETRY_DELAY_INITIAL = 2.0
    +WS_RETRY_DELAY_MAX = 60.0
    +HEALTH_CHECK_INTERVAL = 30.0
    +HEALTH_CHECK_STALE_THRESHOLD = 120.0
    +
    +# Correlation ID prefix for requests we send so we can ignore our own echoes.
    +_CORR_PREFIX = "hermes-"
    +
    +
    +# ---------------------------------------------------------------------------
    +# Helpers
    +# ---------------------------------------------------------------------------
    +
    +def _parse_comma_list(value: str) -> List[str]:
    +    """Split a comma-separated string into a stripped list."""
    +    return [v.strip() for v in value.split(",") if v.strip()]
    +
    +
    +def _guess_extension(data: bytes) -> str:
    +    """Guess file extension from magic bytes."""
    +    if data[:4] == b"\x89PNG":
    +        return ".png"
    +    if data[:2] == b"\xff\xd8":
    +        return ".jpg"
    +    if data[:4] == b"GIF8":
    +        return ".gif"
    +    if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
    +        return ".webp"
    +    if data[:4] == b"%PDF":
    +        return ".pdf"
    +    if len(data) >= 8 and data[4:8] == b"ftyp":
    +        return ".mp4"
    +    if data[:4] == b"OggS":
    +        return ".ogg"
    +    if len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0:
    +        return ".mp3"
    +    return ".bin"
    +
    +
    +def _is_image_ext(ext: str) -> bool:
    +    return ext.lower() in (".jpg", ".jpeg", ".png", ".gif", ".webp")
    +
    +
    +def _is_audio_ext(ext: str) -> bool:
    +    return ext.lower() in (".mp3", ".wav", ".ogg", ".m4a", ".aac")
    +
    +
    +# ---------------------------------------------------------------------------
    +# SimpleX Adapter
    +# ---------------------------------------------------------------------------
    +
    +class SimplexAdapter(BasePlatformAdapter):
    +    """SimpleX Chat adapter using the simplex-chat daemon WebSocket API.
    +
    +    Instantiated by the ``adapter_factory`` passed to
    +    ``ctx.register_platform()`` in :func:`register`.
    +    """
    +
    +    def __init__(self, config: PlatformConfig, **kwargs):
    +        platform = Platform("simplex")
    +        super().__init__(config=config, platform=platform)
    +
    +        extra = getattr(config, "extra", {}) or {}
    +        self.ws_url = extra.get("ws_url", "ws://127.0.0.1:5225").rstrip("/")
    +
    +        # Running state
    +        self._ws = None  # websockets connection
    +        self._ws_task: Optional[asyncio.Task] = None
    +        self._health_task: Optional[asyncio.Task] = None
    +        self._typing_tasks: Dict[str, asyncio.Task] = {}
    +        self._running = False
    +        self._last_ws_activity = 0.0
    +
    +        # Track sent correlation IDs to filter echoes
    +        self._pending_corr_ids: set = set()
    +        self._max_pending_corr = 200
    +
    +        logger.info("SimpleX adapter initialized: url=%s", self.ws_url)
    +
    +    # ------------------------------------------------------------------
    +    # Lifecycle
    +    # ------------------------------------------------------------------
    +
    +    async def connect(self) -> bool:
    +        """Connect to the simplex-chat daemon and start the WebSocket listener."""
    +        try:
    +            import websockets  # noqa: F401
    +        except ImportError:
    +            logger.error(
    +                "SimpleX: 'websockets' package not installed. "
    +                "Run: pip install websockets"
    +            )
    +            return False
    +
    +        if not self.ws_url:
    +            logger.error("SimpleX: SIMPLEX_WS_URL is required")
    +            return False
    +
    +        # Quick connectivity check — try to open and immediately close
    +        try:
    +            import websockets as _wsclient
    +            async with _wsclient.connect(self.ws_url, open_timeout=10):
    +                pass
    +        except Exception as e:
    +            logger.error("SimpleX: cannot reach daemon at %s: %s", self.ws_url, e)
    +            return False
    +
    +        self._running = True
    +        self._last_ws_activity = time.time()
    +        self._ws_task = asyncio.create_task(self._ws_listener())
    +        self._health_task = asyncio.create_task(self._health_monitor())
    +
    +        logger.info("SimpleX: connected to %s", self.ws_url)
    +        return True
    +
    +    async def disconnect(self) -> None:
    +        """Stop WebSocket listener and clean up."""
    +        self._running = False
    +
    +        if self._ws_task:
    +            self._ws_task.cancel()
    +            try:
    +                await self._ws_task
    +            except asyncio.CancelledError:
    +                pass
    +
    +        if self._health_task:
    +            self._health_task.cancel()
    +            try:
    +                await self._health_task
    +            except asyncio.CancelledError:
    +                pass
    +
    +        for task in self._typing_tasks.values():
    +            task.cancel()
    +        self._typing_tasks.clear()
    +
    +        if self._ws:
    +            try:
    +                await self._ws.close()
    +            except Exception:
    +                pass
    +            self._ws = None
    +
    +        logger.info("SimpleX: disconnected")
    +
    +    # ------------------------------------------------------------------
    +    # WebSocket listener
    +    # ------------------------------------------------------------------
    +
    +    async def _ws_listener(self) -> None:
    +        """Maintain a persistent WebSocket connection to the daemon."""
    +        import websockets as _wsclient
    +        import websockets as _wsexc
    +
    +        backoff = WS_RETRY_DELAY_INITIAL
    +
    +        while self._running:
    +            try:
    +                logger.debug("SimpleX WS: connecting to %s", self.ws_url)
    +                async with _wsclient.connect(
    +                    self.ws_url,
    +                    ping_interval=20,
    +                    ping_timeout=20,
    +                ) as ws:
    +                    self._ws = ws
    +                    backoff = WS_RETRY_DELAY_INITIAL
    +                    self._last_ws_activity = time.time()
    +                    logger.info("SimpleX WS: connected")
    +
    +                    async for raw in ws:
    +                        if not self._running:
    +                            break
    +                        self._last_ws_activity = time.time()
    +                        try:
    +                            msg = json.loads(raw)
    +                            await self._handle_event(msg)
    +                        except json.JSONDecodeError:
    +                            logger.debug("SimpleX WS: invalid JSON: %.100s", raw)
    +                        except Exception:
    +                            logger.exception("SimpleX WS: error handling event")
    +
    +            except asyncio.CancelledError:
    +                break
    +            except _wsexc.WebSocketException as e:
    +                if self._running:
    +                    logger.warning(
    +                        "SimpleX WS: error: %s (reconnecting in %.0fs)", e, backoff
    +                    )
    +            except Exception as e:
    +                if self._running:
    +                    logger.warning(
    +                        "SimpleX WS: unexpected error: %s (reconnecting in %.0fs)",
    +                        e, backoff,
    +                    )
    +            finally:
    +                self._ws = None
    +
    +            if self._running:
    +                jitter = backoff * 0.2 * random.random()
    +                await asyncio.sleep(backoff + jitter)
    +                backoff = min(backoff * 2, WS_RETRY_DELAY_MAX)
    +
    +    # ------------------------------------------------------------------
    +    # Health monitor
    +    # ------------------------------------------------------------------
    +
    +    async def _health_monitor(self) -> None:
    +        """Force reconnect if the WebSocket has been idle too long."""
    +        while self._running:
    +            await asyncio.sleep(HEALTH_CHECK_INTERVAL)
    +            if not self._running:
    +                break
    +
    +            elapsed = time.time() - self._last_ws_activity
    +            if elapsed > HEALTH_CHECK_STALE_THRESHOLD:
    +                logger.warning(
    +                    "SimpleX: WS idle for %.0fs, forcing reconnect", elapsed
    +                )
    +                self._last_ws_activity = time.time()
    +                if self._ws:
    +                    try:
    +                        await self._ws.close()
    +                    except Exception:
    +                        pass
    +
    +    # ------------------------------------------------------------------
    +    # Inbound event handling
    +    # ------------------------------------------------------------------
    +
    +    async def _handle_event(self, event: dict) -> None:
    +        """Dispatch a daemon event to the appropriate handler."""
    +        resp_type = event.get("type") or event.get("resp", {}).get("type", "")
    +
    +        # Filter responses to our own commands (echoes)
    +        corr_id = event.get("corrId", "")
    +        if corr_id and corr_id.startswith(_CORR_PREFIX):
    +            self._pending_corr_ids.discard(corr_id)
    +            return
    +
    +        if resp_type == "newChatItem":
    +            await self._handle_new_chat_item(event)
    +        elif resp_type == "newChatItems":
    +            # Batch variant — process each item
    +            items = event.get("chatItems") or []
    +            for item_wrapper in items:
    +                await self._handle_new_chat_item(item_wrapper)
    +        # Ignore all other event types (delivery receipts, contact updates, etc.)
    +
    +    async def _handle_new_chat_item(self, wrapper: dict) -> None:
    +        """Process a single newChatItem event into a MessageEvent."""
    +        # The daemon wraps the chat item differently depending on version;
    +        # normalise both layouts.
    +        chat_info = wrapper.get("chatInfo") or wrapper.get("chat") or {}
    +        chat_item = wrapper.get("chatItem") or wrapper.get("item") or {}
    +
    +        # Only process messages (not calls, deleted items, etc.)
    +        item_content = chat_item.get("content") or {}
    +        msg_content = item_content.get("msgContent") or {}
    +        if not msg_content:
    +            return
    +
    +        # Filter out messages sent by us (direction == "snd")
    +        meta = chat_item.get("meta") or {}
    +        direction = (meta.get("itemStatus") or {}).get("type", "")
    +        if direction in ("sndSent", "sndSentDirect", "sndSentViaProxy", "sndNew"):
    +            return
    +
    +        # Determine chat type and IDs
    +        chat_type_raw = chat_info.get("type", "")
    +        is_group = chat_type_raw in ("group", "groupInfo")
    +
    +        if is_group:
    +            group_info = chat_info.get("groupInfo") or chat_info.get("group") or {}
    +            group_id = str(group_info.get("groupId") or group_info.get("id") or "")
    +            group_name = group_info.get("displayName") or group_info.get("groupProfile", {}).get("displayName", "")
    +            chat_id = f"group:{group_id}" if group_id else ""
    +            chat_name = group_name
    +        else:
    +            contact_info = chat_info.get("contact") or {}
    +            contact_id = str(contact_info.get("contactId") or contact_info.get("id") or "")
    +            contact_name = (
    +                contact_info.get("displayName")
    +                or contact_info.get("localDisplayName")
    +                or contact_id
    +            )
    +            chat_id = contact_id
    +            chat_name = contact_name
    +
    +        if not chat_id:
    +            logger.debug("SimpleX: ignoring event with no chat_id")
    +            return
    +
    +        # Sender — for groups the message includes a chatItemMember sub-object
    +        member = chat_item.get("chatItemMember") or {}
    +        if is_group and member:
    +            sender_id = str(member.get("memberId") or member.get("id") or chat_id)
    +            sender_name = (
    +                member.get("displayName")
    +                or member.get("localDisplayName")
    +                or sender_id
    +            )
    +        else:
    +            sender_id = chat_id
    +            sender_name = chat_name
    +
    +        # Extract text
    +        text = msg_content.get("text") or ""
    +
    +        # Media attachments
    +        media_urls: List[str] = []
    +        media_types: List[str] = []
    +        file_info = chat_item.get("file") or {}
    +        if file_info and file_info.get("fileStatus") not in ("cancelled", "error"):
    +            file_id = file_info.get("fileId")
    +            file_name = file_info.get("fileName", "file")
    +            if file_id:
    +                try:
    +                    cached = await self._fetch_file(file_id, file_name)
    +                    if cached:
    +                        ext = cached.rsplit(".", 1)[-1]
    +                        if _is_image_ext("." + ext):
    +                            media_types.append("image/" + ext.replace("jpg", "jpeg"))
    +                        elif _is_audio_ext("." + ext):
    +                            media_types.append("audio/" + ext)
    +                        else:
    +                            media_types.append("application/octet-stream")
    +                        media_urls.append(cached)
    +                except Exception:
    +                    logger.exception("SimpleX: failed to fetch file %s", file_id)
    +
    +        # Timestamp
    +        ts_str = meta.get("itemTs") or meta.get("createdAt") or ""
    +        try:
    +            timestamp = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
    +        except (ValueError, AttributeError):
    +            timestamp = datetime.now(tz=timezone.utc)
    +
    +        # Build source
    +        source = self.build_source(
    +            chat_id=chat_id,
    +            chat_name=chat_name,
    +            chat_type="group" if is_group else "dm",
    +            user_id=sender_id,
    +            user_name=sender_name,
    +        )
    +
    +        # Message type
    +        msg_type = MessageType.TEXT
    +        if media_types:
    +            if any(mt.startswith("audio/") for mt in media_types):
    +                msg_type = MessageType.VOICE
    +            elif any(mt.startswith("image/") for mt in media_types):
    +                msg_type = MessageType.PHOTO
    +
    +        event_obj = MessageEvent(
    +            source=source,
    +            text=text,
    +            message_type=msg_type,
    +            media_urls=media_urls,
    +            media_types=media_types,
    +            timestamp=timestamp,
    +            raw_message=wrapper,
    +        )
    +
    +        await self.handle_message(event_obj)
    +
    +    async def _fetch_file(self, file_id: Any, file_name: str) -> Optional[str]:
    +        """Ask the daemon to receive and return a file attachment."""
    +        # simplex-chat exposes `/api/v1/files/{fileId}` on an HTTP port
    +        # when started with --http-port. However, the canonical WebSocket API
    +        # does not have a direct binary download command; files are stored on
    +        # the local filesystem after the daemon accepts them.
    +        #
    +        # We request acceptance first, then read from the daemon's local path.
    +        corr_id = self._make_corr_id()
    +        cmd = {
    +            "corrId": corr_id,
    +            "cmd": f"/freceive {file_id}",
    +        }
    +        await self._send_ws(cmd)
    +        # The daemon will emit a chatItemUpdated event when the file lands;
    +        # for simplicity we just wait briefly and rely on the daemon's default path.
    +        await asyncio.sleep(2)
    +
    +        # simplex-chat stores received files in ~/Downloads or a configured path.
    +        # We try common locations.
    +        for search_dir in (
    +            os.path.expanduser("~/Downloads"),
    +            os.path.expanduser("~/.simplex/files"),
    +            "/tmp/simplex_files",
    +        ):
    +            candidate = os.path.join(search_dir, file_name)
    +            if os.path.exists(candidate):
    +                with open(candidate, "rb") as f:
    +                    data = f.read()
    +                ext = _guess_extension(data)
    +                if _is_image_ext(ext):
    +                    return cache_image_from_bytes(data, ext)
    +                elif _is_audio_ext(ext):
    +                    return cache_audio_from_bytes(data, ext)
    +                else:
    +                    return cache_document_from_bytes(data, file_name)
    +        return None
    +
    +    # ------------------------------------------------------------------
    +    # Outbound messages
    +    # ------------------------------------------------------------------
    +
    +    def _make_corr_id(self) -> str:
    +        """Generate a unique correlation ID for a request."""
    +        corr_id = f"{_CORR_PREFIX}{int(time.time() * 1000)}-{random.randint(0, 9999)}"
    +        self._pending_corr_ids.add(corr_id)
    +        if len(self._pending_corr_ids) > self._max_pending_corr:
    +            # Trim oldest — sets are unordered so just clear the oldest half
    +            to_remove = list(self._pending_corr_ids)[:self._max_pending_corr // 2]
    +            self._pending_corr_ids -= set(to_remove)
    +        return corr_id
    +
    +    async def _send_ws(self, payload: dict) -> None:
    +        """Send a JSON payload over the WebSocket, queuing if not yet connected."""
    +        import websockets as _wsexc
    +        ws = self._ws
    +        if not ws:
    +            logger.debug("SimpleX: WS not connected, dropping outbound command")
    +            return
    +        try:
    +            await ws.send(json.dumps(payload))
    +        except _wsexc.ConnectionClosed:
    +            logger.warning("SimpleX: WS closed while sending")
    +        except Exception as e:
    +            logger.warning("SimpleX: WS send error: %s", e)
    +
    +    async def send(
    +        self,
    +        chat_id: str,
    +        content: str,
    +        reply_to: Optional[str] = None,
    +        metadata: Optional[Dict[str, Any]] = None,
    +    ) -> SendResult:
    +        """Send a text message to a contact or group."""
    +        corr_id = self._make_corr_id()
    +
    +        if chat_id.startswith("group:"):
    +            group_id = chat_id[6:]
    +            cmd_str = f"#[{group_id}] {content}"
    +        else:
    +            cmd_str = f"@[{chat_id}] {content}"
    +
    +        payload = {
    +            "corrId": corr_id,
    +            "cmd": cmd_str,
    +        }
    +
    +        await self._send_ws(payload)
    +        return SendResult(success=True)
    +
    +    async def send_typing(self, chat_id: str, metadata=None) -> None:
    +        """SimpleX does not expose a typing indicator API — no-op."""
    +        pass
    +
    +    async def send_image(
    +        self,
    +        chat_id: str,
    +        image_url: str,
    +        caption: Optional[str] = None,
    +        reply_to: Optional[str] = None,
    +        metadata: Optional[Dict[str, Any]] = None,
    +    ) -> SendResult:
    +        """Send an image (URL) as a message with optional caption.
    +
    +        SimpleX has no native ``send_image`` over the WebSocket API — file
    +        attachments require the daemon's filesystem-backed flow which is
    +        not driven from this adapter. Fall back to a plain text message
    +        containing the URL and caption.
    +        """
    +        text = f"{caption}\n{image_url}".strip() if caption else image_url
    +        return await self.send(chat_id, text, reply_to=reply_to, metadata=metadata)
    +
    +    async def get_chat_info(self, chat_id: str) -> dict:
    +        """Return basic chat info."""
    +        if chat_id.startswith("group:"):
    +            return {"chat_id": chat_id, "type": "group", "name": chat_id[6:]}
    +        return {"chat_id": chat_id, "type": "dm", "name": chat_id}
    +
    +
    +# ---------------------------------------------------------------------------
    +# Plugin entry-point hooks
    +# ---------------------------------------------------------------------------
    +
    +def check_requirements() -> bool:
    +    """Plugin gate: require SIMPLEX_WS_URL AND the websockets package.
    +
    +    Returning False keeps the platform out of ``get_connected_platforms()``
    +    so the gateway never instantiates the adapter when the dependency is
    +    missing or no daemon URL is configured.
    +    """
    +    if not os.getenv("SIMPLEX_WS_URL"):
    +        return False
    +    try:
    +        import websockets  # noqa: F401
    +    except ImportError:
    +        return False
    +    return True
    +
    +
    +def validate_config(config) -> bool:
    +    """Validate that the platform config has enough info to connect."""
    +    extra = getattr(config, "extra", {}) or {}
    +    ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "")
    +    return bool(ws_url)
    +
    +
    +def is_connected(config) -> bool:
    +    """Check whether SimpleX is configured (env or config.yaml)."""
    +    extra = getattr(config, "extra", {}) or {}
    +    ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "")
    +    return bool(ws_url)
    +
    +
    +def _env_enablement() -> dict | None:
    +    """Seed ``PlatformConfig.extra`` from env vars during gateway config load.
    +
    +    Called by the platform registry's env-enablement hook BEFORE adapter
    +    construction, so ``gateway status`` and ``get_connected_platforms()``
    +    reflect env-only configuration without instantiating the WebSocket
    +    client. Returns ``None`` when SimpleX isn't minimally configured.
    +
    +    The special ``home_channel`` key in the returned dict is handled by
    +    the core hook — it becomes a proper ``HomeChannel`` dataclass on the
    +    ``PlatformConfig`` rather than being merged into ``extra``.
    +    """
    +    ws_url = os.getenv("SIMPLEX_WS_URL", "").strip()
    +    if not ws_url:
    +        return None
    +    seed: dict = {"ws_url": ws_url}
    +    home = os.getenv("SIMPLEX_HOME_CHANNEL", "").strip()
    +    if home:
    +        seed["home_channel"] = {
    +            "chat_id": home,
    +            "name": os.getenv("SIMPLEX_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]:
    +    """Open an ephemeral WebSocket to the daemon, send, and close.
    +
    +    Used by ``tools/send_message_tool._send_via_adapter`` when the gateway
    +    runner is not in this process (e.g. ``hermes cron`` running as a
    +    separate process from ``hermes gateway``). Without this hook,
    +    ``deliver=simplex`` cron jobs fail with "No live adapter for platform".
    +
    +    ``thread_id`` and ``force_document`` are accepted for signature parity
    +    with other plugins but are not meaningful here. ``media_files`` is
    +    accepted but only the text body is delivered — SimpleX requires the
    +    daemon's filesystem-backed file flow which an ephemeral connection
    +    cannot drive safely.
    +    """
    +    try:
    +        import websockets as _wsclient
    +    except ImportError:
    +        return {"error": "websockets not installed. Run: pip install websockets"}
    +
    +    extra = getattr(pconfig, "extra", {}) or {}
    +    ws_url = os.getenv("SIMPLEX_WS_URL") or extra.get("ws_url", "ws://127.0.0.1:5225")
    +    if not ws_url:
    +        return {"error": "SimpleX standalone send: SIMPLEX_WS_URL is required"}
    +
    +    try:
    +        if chat_id.startswith("group:"):
    +            group_id = chat_id[6:]
    +            cmd_str = f"#[{group_id}] {message}"
    +        else:
    +            cmd_str = f"@[{chat_id}] {message}"
    +
    +        payload = {
    +            "corrId": f"hermes-snd-{int(time.time() * 1000)}",
    +            "cmd": cmd_str,
    +        }
    +
    +        async with _wsclient.connect(ws_url, open_timeout=10, close_timeout=5) as ws:
    +            await ws.send(json.dumps(payload))
    +            # Give the daemon a moment to process the command before closing.
    +            await asyncio.sleep(0.5)
    +
    +        return {"success": True, "platform": "simplex", "chat_id": chat_id}
    +    except Exception as e:
    +        return {"error": f"SimpleX send failed: {e}"}
    +
    +
    +def interactive_setup() -> None:
    +    """Minimal stdin wizard for ``hermes setup gateway`` → SimpleX.
    +
    +    Prompts for the WebSocket URL and the optional allowlist / home channel.
    +    Writes to ``~/.hermes/.env`` via ``hermes_cli.config``.
    +    """
    +    print()
    +    print("SimpleX Chat setup")
    +    print("------------------")
    +    print("Requirements:")
    +    print("  1. simplex-chat daemon running (e.g. `simplex-chat -p 5225`).")
    +    print("  2. Python package `websockets` installed (`pip install websockets`).")
    +    print()
    +
    +    try:
    +        from hermes_cli.config import get_env_value, save_env_value
    +    except ImportError:
    +        print("hermes_cli.config not available; set SIMPLEX_* vars manually in ~/.hermes/.env")
    +        return
    +
    +    def _prompt(var: str, prompt: str, *, secret: bool = False) -> None:
    +        existing = get_env_value(var) if callable(get_env_value) else None
    +        suffix = " [keep current]" if existing else ""
    +        try:
    +            if secret:
    +                import getpass
    +                value = getpass.getpass(f"{prompt}{suffix}: ")
    +            else:
    +                value = input(f"{prompt}{suffix}: ").strip()
    +        except (EOFError, KeyboardInterrupt):
    +            print()
    +            return
    +        if value:
    +            save_env_value(var, value)
    +
    +    _prompt("SIMPLEX_WS_URL", "Daemon WebSocket URL (default ws://127.0.0.1:5225)")
    +    _prompt("SIMPLEX_ALLOWED_USERS", "Allowed contact IDs (comma-separated; blank=skip)")
    +    _prompt("SIMPLEX_HOME_CHANNEL", "Home channel contact/group ID (or empty)")
    +    print("Done. Make sure the simplex-chat daemon is running before starting the gateway.")
    +
    +
    +def register(ctx) -> None:
    +    """Plugin entry point — called by the Hermes plugin system at startup."""
    +    ctx.register_platform(
    +        name="simplex",
    +        label="SimpleX Chat",
    +        adapter_factory=lambda cfg: SimplexAdapter(cfg),
    +        check_fn=check_requirements,
    +        validate_config=validate_config,
    +        is_connected=is_connected,
    +        required_env=["SIMPLEX_WS_URL"],
    +        install_hint="pip install websockets   # SimpleX adapter requires the websockets package",
    +        setup_fn=interactive_setup,
    +        # Env-driven auto-configuration: seeds PlatformConfig.extra so
    +        # env-only setups show up in `hermes gateway status` without
    +        # instantiating the adapter.
    +        env_enablement_fn=_env_enablement,
    +        # Cron home-channel delivery support — `deliver=simplex` cron jobs
    +        # route to SIMPLEX_HOME_CHANNEL when set.
    +        cron_deliver_env_var="SIMPLEX_HOME_CHANNEL",
    +        # Out-of-process cron delivery. Without this hook, deliver=simplex
    +        # cron jobs fail with "No live adapter" when cron runs separately
    +        # from the gateway.
    +        standalone_sender_fn=_standalone_send,
    +        # Auth env vars for _is_user_authorized() integration
    +        allowed_users_env="SIMPLEX_ALLOWED_USERS",
    +        allow_all_env="SIMPLEX_ALLOW_ALL_USERS",
    +        # SimpleX has no hard line length; we still chunk for sanity.
    +        max_message_length=MAX_MESSAGE_LENGTH,
    +        # Display
    +        emoji="🔒",
    +        # SimpleX uses opaque contact IDs only — no phone numbers or
    +        # email addresses to redact.
    +        pii_safe=True,
    +        allow_update_command=True,
    +        # LLM guidance
    +        platform_hint=(
    +            "You are chatting via SimpleX Chat, a private decentralised "
    +            "messenger. Contacts are identified by opaque internal IDs, "
    +            "not phone numbers or usernames. SimpleX supports standard "
    +            "markdown formatting. There is no typing indicator and no "
    +            "hard message length limit, but keep responses conversational."
    +        ),
    +    )
    ```
  - `plugins/platforms/simplex/plugin.yaml`

    ```diff
    @@ -0,0 +1,37 @@
    +name: simplex-platform
    +label: SimpleX Chat
    +kind: platform
    +version: 1.0.0
    +description: >
    +  SimpleX Chat gateway adapter for Hermes Agent.
    +  Connects to a local simplex-chat daemon via WebSocket and relays
    +  messages between SimpleX contacts/groups and the Hermes agent.
    +  SimpleX is decentralised and assigns no persistent user IDs —
    +  every contact is an opaque internal ID generated at connection
    +  time, making it one of the most private messengers available.
    +author: Mibayy
    +# ``requires_env`` and ``optional_env`` entries are surfaced in the
    +# ``hermes config`` UI via the platform-plugin env var injector in
    +# ``hermes_cli/config.py``.
    +requires_env:
    +  - name: SIMPLEX_WS_URL
    +    description: "WebSocket URL of the simplex-chat daemon (e.g. ws://127.0.0.1:5225)"
    +    prompt: "SimpleX daemon WebSocket URL"
    +    password: false
    +optional_env:
    +  - name: SIMPLEX_ALLOWED_USERS
    +    description: "Comma-separated SimpleX contact IDs allowed to talk to the bot"
    +    prompt: "Allowed contact IDs (comma-separated)"
    +    password: false
    +  - name: SIMPLEX_ALLOW_ALL_USERS
    +    description: "Allow any contact to talk to the bot (dev only — disables allowlist)"
    +    prompt: "Allow all contacts? (true/false)"
    +    password: false
    +  - name: SIMPLEX_HOME_CHANNEL
    +    description: "Default contact/group ID for cron / notification delivery"
    +    prompt: "Home channel contact/group ID (or empty)"
    +    password: false
    +  - name: SIMPLEX_HOME_CHANNEL_NAME
    +    description: "Human label for the home channel (defaults to the ID)"
    +    prompt: "Home channel display name (or empty)"
    +    password: false
    ```
  - `scripts/release.py`

    ```diff
    @@ -397,6 +397,7 @@
         "Mibayy@users.noreply.github.com": "Mibayy",
         "mibayy@users.noreply.github.com": "Mibayy",
         "[redacted:email]": "Mibayy",
    +    "[redacted:email]": "Mibayy",
         "135070653+sgaofen@users.noreply.github.com": "sgaofen",
         "[redacted:email]": "zhiyanliu",
         "[redacted:email]": "hhhonzik",
    ```
  - `tests/gateway/test_simplex_plugin.py`

    ```diff
    @@ -0,0 +1,347 @@
    +"""Tests for the SimpleX Chat platform-plugin adapter.
    +
    +Loaded via the ``_plugin_adapter_loader`` helper so this lives under
    +``plugin_adapter_simplex`` in ``sys.modules`` and cannot collide with
    +sibling platform-plugin tests on the same xdist worker.
    +"""
    +
    +from __future__ import annotations
    +
    +import json
    +import os
    +from unittest.mock import AsyncMock, MagicMock
    +
    +import pytest
    +
    +from tests.gateway._plugin_adapter_loader import load_plugin_adapter
    +
    +_simplex = load_plugin_adapter("simplex")
    +
    +SimplexAdapter = _simplex.SimplexAdapter
    +check_requirements = _simplex.check_requirements
    +validate_config = _simplex.validate_config
    +is_connected = _simplex.is_connected
    +register = _simplex.register
    +_env_enablement = _simplex._env_enablement
    +_standalone_send = _simplex._standalone_send
    +_guess_extension = _simplex._guess_extension
    +_is_image_ext = _simplex._is_image_ext
    +_is_audio_ext = _simplex._is_audio_ext
    +_CORR_PREFIX = _simplex._CORR_PREFIX
    +
    +
    +# ---------------------------------------------------------------------------
    +# 1. Platform enum (plugin-discovered, not bundled)
    +# ---------------------------------------------------------------------------
    +
    +def test_platform_enum_resolves_via_plugin_scan():
    +    """The plugin filesystem scan should expose Platform("simplex")."""
    +    from gateway.config import Platform
    +    p = Platform("simplex")
    +    assert p.value == "simplex"
    +    # Identity stability — repeated lookups return the same pseudo-member
    +    assert Platform("simplex") is p
    +
    +
    +# ---------------------------------------------------------------------------
    +# 2. check_requirements / validate_config / is_connected
    +# ---------------------------------------------------------------------------
    +
    +def test_check_requirements_needs_url(monkeypatch):
    +    monkeypatch.delenv("SIMPLEX_WS_URL", raising=False)
    +    assert check_requirements() is False
    +
    +
    +def test_check_requirements_true_when_configured(monkeypatch):
    +    monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225")
    +    # websockets is a dev dep in this repo via the test plugins; the
    +    # check_requirements() gate also asserts the package imports.
    +    websockets_present = True
    +    try:
    +        import websockets  # noqa: F401
    +    except ImportError:
    +        websockets_present = False
    +    assert check_requirements() is websockets_present
    +
    +
    +def test_validate_config_uses_env_or_extra():
    +    from gateway.config import PlatformConfig
    +    # Empty extra + no env → invalid
    +    cfg = PlatformConfig(enabled=True)
    +    assert validate_config(cfg) is False
    +    # extra-only path → valid
    +    cfg2 = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
    +    assert validate_config(cfg2) is True
    +
    +
    +def test_is_connected_mirrors_validate(monkeypatch):
    +    from gateway.config import PlatformConfig
    +    monkeypatch.delenv("SIMPLEX_WS_URL", raising=False)
    +    cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://x"})
    +    assert is_connected(cfg) is True
    +    assert is_connected(PlatformConfig(enabled=True)) is False
    +
    +
    +# ---------------------------------------------------------------------------
    +# 3. _env_enablement seeds PlatformConfig.extra
    +# ---------------------------------------------------------------------------
    +
    +def test_env_enablement_none_when_unset(monkeypatch):
    +    monkeypatch.delenv("SIMPLEX_WS_URL", raising=False)
    +    assert _env_enablement() is None
    +
    +
    +def test_env_enablement_seeds_ws_url(monkeypatch):
    +    monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225")
    +    monkeypatch.delenv("SIMPLEX_HOME_CHANNEL", raising=False)
    +    seed = _env_enablement()
    +    assert seed == {"ws_url": "ws://127.0.0.1:5225"}
    +
    +
    +def test_env_enablement_seeds_home_channel(monkeypatch):
    +    monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225")
    +    monkeypatch.setenv("SIMPLEX_HOME_CHANNEL", "42")
    +    monkeypatch.setenv("SIMPLEX_HOME_CHANNEL_NAME", "Personal")
    +    seed = _env_enablement()
    +    assert seed["home_channel"] == {"chat_id": "42", "name": "Personal"}
    +
    +
    +def test_env_enablement_home_channel_defaults_name_to_id(monkeypatch):
    +    monkeypatch.setenv("SIMPLEX_WS_URL", "ws://127.0.0.1:5225")
    +    monkeypatch.setenv("SIMPLEX_HOME_CHANNEL", "42")
    +    monkeypatch.delenv("SIMPLEX_HOME_CHANNEL_NAME", raising=False)
    +    seed = _env_enablement()
    +    assert seed["home_channel"] == {"chat_id": "42", "name": "42"}
    +
    +
    +# ---------------------------------------------------------------------------
    +# 4. Adapter init
    +# ---------------------------------------------------------------------------
    +
    +def test_adapter_init_custom_url():
    +    from gateway.config import PlatformConfig
    +    cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
    +    adapter = SimplexAdapter(cfg)
    +    assert adapter.ws_url == "ws://localhost:5225"
    +    assert adapter._running is False
    +    assert adapter._ws is None
    +
    +
    +def test_adapter_init_default_url():
    +    from gateway.config import PlatformConfig
    +    cfg = PlatformConfig(enabled=True)
    +    adapter = SimplexAdapter(cfg)
    +    assert adapter.ws_url == "ws://127.0.0.1:5225"
    +
    +
    +def test_adapter_platform_identity():
    +    """Adapter should expose Platform("simplex") identity."""
    +    from gateway.config import Platform, PlatformConfig
    +    cfg = PlatformConfig(enabled=True)
    +    adapter = SimplexAdapter(cfg)
    +    assert adapter.platform is Platform("simplex")
    +
    +
    +# ---------------------------------------------------------------------------
    +# 5. Helper functions (magic-byte detection)
    +# ---------------------------------------------------------------------------
    +
    +def test_guess_extension_png():
    +    assert _guess_extension(b"\x89PNG\r\n\x1a\n") == ".png"
    +
    +
    +def test_guess_extension_jpg():
    +    assert _guess_extension(b"\xff\xd8\xff\xe0") == ".jpg"
    +
    +
    +def test_guess_extension_ogg():
    +    assert _guess_extension(b"OggS\x00\x02") == ".ogg"
    +
    +
    +def test_guess_extension_unknown():
    +    assert _guess_extension(b"\x00\x01\x02\x03") == ".bin"
    +
    +
    +def test_is_image_ext():
    +    assert _is_image_ext(".png") is True
    +    assert _is_image_ext(".webp") is True
    +    assert _is_image_ext(".ogg") is False
    +
    +
    +def test_is_audio_ext():
    +    assert _is_audio_ext(".ogg") is True
    +    assert _is_audio_ext(".mp3") is True
    +    assert _is_audio_ext(".pdf") is False
    +
    +
    +# ---------------------------------------------------------------------------
    +# 6. Correlation IDs
    +# ---------------------------------------------------------------------------
    +
    +def test_corr_id_starts_with_prefix_and_tracks_pending():
    +    from gateway.config import PlatformConfig
    +    cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
    +    adapter = SimplexAdapter(cfg)
    +    corr_id = adapter._make_corr_id()
    +    assert corr_id.startswith(_CORR_PREFIX)
    +    assert corr_id in adapter._pending_corr_ids
    +
    +
    +def test_corr_id_pending_set_self_trims():
    +    from gateway.config import PlatformConfig
    +    cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
    +    adapter = SimplexAdapter(cfg)
    +    adapter._max_pending_corr = 4
    +    for _ in range(10):
    +        adapter._make_corr_id()
    +    # After many additions, the pending set should be bounded by the trim
    +    # logic — at most one trim window above the cap.
    +    assert len(adapter._pending_corr_ids) <= adapter._max_pending_corr + 1
    +
    +
    +# ---------------------------------------------------------------------------
    +# 7. Outbound send (mocked WS)
    +# ---------------------------------------------------------------------------
    +
    [redacted:email]
    +async def test_send_dm():
    +    from gateway.config import PlatformConfig
    +    cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
    +    adapter = SimplexAdapter(cfg)
    +
    +    mock_ws = AsyncMock()
    +    adapter._ws = mock_ws
    +
    +    result = await adapter.send("contact-42", "Hello, SimpleX!")
    +    mock_ws.send.assert_called_once()
    +    payload = json.loads(mock_ws.send.call_args[0][0])
    +    assert payload["cmd"] == "@[contact-42] Hello, SimpleX!"
    +    assert payload["corrId"].startswith(_CORR_PREFIX)
    +    assert result.success is True
    +
    +
    [redacted:email]
    +async def test_send_group():
    +    from gateway.config import PlatformConfig
    +    cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
    +    adapter = SimplexAdapter(cfg)
    +
    +    mock_ws = AsyncMock()
    +    adapter._ws = mock_ws
    +
    +    result = await adapter.send("group:grp-99", "Hello, group!")
    +    payload = json.loads(mock_ws.send.call_args[0][0])
    +    assert payload["cmd"] == "#[grp-99] Hello, group!"
    +    assert result.success is True
    +
    +
    [redacted:email]
    +async def test_send_when_ws_not_connected_does_not_crash():
    +    from gateway.config import PlatformConfig
    +    cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
    +    adapter = SimplexAdapter(cfg)
    +    # No _ws assigned — _send_ws should drop quietly
    +    result = await adapter.send("contact-42", "hi")
    +    assert result.success is True  # send() always returns success — fire-and-forget
    +
    +
    +# ---------------------------------------------------------------------------
    +# 8. Inbound: filter own-echo by corrId prefix
    +# ---------------------------------------------------------------------------
    +
    [redacted:email]
    +async def test_handle_event_filters_own_corr_id():
    +    from gateway.config import PlatformConfig
    +    cfg = PlatformConfig(enabled=True, extra={"ws_url": "ws://localhost:5225"})
    +    adapter = SimplexAdapter(cfg)
    +    # Pretend we sent a command with this corrId
    +    own = adapter._make_corr_id()
    +    handler_mock = AsyncMock()
    +    adapter._handle_new_chat_item = handler_mock  # type: ignore
    +
    +    await adapter._handle_event({"corrId": own, "type": "newChatItem"})
    +    handler_mock.assert_not_called()
    +    assert own not in adapter._pending_corr_ids  # discarded
    +
    +
    +# ---------------------------------------------------------------------------
    +# 9. Standalone (out-of-process) send for cron
    +# ---------------------------------------------------------------------------
    +
    [redacted:email]
    +async def test_standalone_send_missing_websockets(monkeypatch):
    +    """When websockets is unimportable, return a clean error dict.
    +
    +    Implementation detail: the standalone path does ``import websockets``
    +    inside the function body. We simulate the package being absent by
    +    pulling it out of ``sys.modules`` and pointing the finder at None.
    +    """
    +    import sys
    +    saved_websockets = sys.modules.pop("websockets", None)
    +    saved_meta = list(sys.meta_path)
    +
    +    class _Blocker:
    +        @staticmethod
    +        def find_spec(name, path=None, target=None):
    +            if name == "websockets" or name.startswith("websockets."):
    +                raise ImportError("websockets blocked for test")
    +            return None
    +
    +    sys.meta_path.insert(0, _Blocker())
    +    try:
    +        pconfig = MagicMock()
    +        pconfig.extra = {"ws_url": "ws://localhost:5225"}
    +        result = await _standalone_send(pconfig, "contact-42", "hi")
    +        assert isinstance(result, dict)
    +        assert "error" in result
    +        assert "websockets" in result["error"]
    +    finally:
    +        sys.meta_path[:] = saved_meta
    +        if saved_websockets is not None:
    +            sys.modules["websockets"] = saved_websockets
    +
    +
    [redacted:email]
    +async def test_standalone_send_missing_url(monkeypatch):
    +    monkeypatch.delenv("SIMPLEX_WS_URL", raising=False)
    +    pconfig = MagicMock()
    +    pconfig.extra = {}
    +    # We expect the URL fallback (extra+env both empty) to be empty string,
    +    # producing an error. We also need websockets to be importable for the
    +    # url-check branch to be reached, so skip when it's not.
    +    try:
    +        import websockets.client  # noqa: F401
    +    except ImportError:
    +        pytest.skip("websockets not installed")
    +
    +    result = await _standalone_send(pconfig, "contact-42", "hi")
    +    assert isinstance(result, dict)
    +    # Either error about URL or a connection attempt failure — both are valid
    +    # signals that the standalone path requires configuration.
    +    assert "error" in result
    +
    +
    +# ---------------------------------------------------------------------------
    +# 10. register() — plugin-side metadata
    +# ---------------------------------------------------------------------------
    +
    +def test_register_calls_register_platform():
    +    ctx = MagicMock()
    +    register(ctx)
    +    ctx.register_platform.assert_called_once()
    +    kwargs = ctx.register_platform.call_args.kwargs
    +    assert kwargs["name"] == "simplex"
    +    assert kwargs["label"] == "SimpleX Chat"
    +    assert kwargs["required_env"] == ["SIMPLEX_WS_URL"]
    +    assert kwargs["allowed_users_env"] == "SIMPLEX_ALLOWED_USERS"
    +    assert kwargs["allow_all_env"] == "SIMPLEX_ALLOW_ALL_USERS"
    +    assert kwargs["cron_deliver_env_var"] == "SIMPLEX_HOME_CHANNEL"
    +    assert callable(kwargs["check_fn"])
    +    assert callable(kwargs["validate_config"])
    +    assert callable(kwargs["is_connected"])
    +    assert callable(kwargs["env_enablement_fn"])
    +    assert callable(kwargs["standalone_sender_fn"])
    +    assert callable(kwargs["adapter_factory"])
    +    assert callable(kwargs["setup_fn"])
    +    # SimpleX uses opaque IDs only — no PII to redact.
    +    assert kwargs["pii_safe"] is True
    ```
  - `website/docs/user-guide/messaging/simplex.md`

    ```diff
    @@ -0,0 +1,99 @@
    +# SimpleX Chat
    +
    +[SimpleX Chat](https://simplex.chat/) is a private, decentralised messaging platform where users own their contacts and groups. Unlike other platforms, SimpleX assigns no persistent user IDs — every contact is identified by an opaque internal ID generated at connection time, which makes it one of the most private messengers available.
    +
    +## Prerequisites
    +
    +- The **simplex-chat** CLI installed and running as a daemon
    +- Python package **websockets** (`pip install websockets`)
    +
    +## Install simplex-chat
    +
    +Download the latest release from the [simplex-chat GitHub releases](https://github.com/simplex-chat/simplex-chat/releases) page, or via Docker:
    +
    +```bash
    +# Linux / macOS binary
    +curl -L https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-chat-ubuntu-22_04-x86-64 -o simplex-chat
    +chmod +x simplex-chat
    +
    +# Or Docker
    +docker run -p 5225:5225 simplexchat/simplex-chat -p 5225
    +```
    +
    +## Start the daemon
    +
    +```bash
    +simplex-chat -p 5225
    +```
    +
    +The daemon listens on WebSocket at `ws://127.0.0.1:5225` by default.
    +
    +## Configure Hermes
    +
    +### Via setup wizard
    +
    +```bash
    +hermes setup gateway
    +```
    +
    +Select **SimpleX Chat** and follow the prompts.
    +
    +### Via environment variables
    +
    +Add these to `~/.hermes/.env`:
    +
    +```
    +SIMPLEX_WS_URL=ws://127.0.0.1:5225
    +SIMPLEX_ALLOWED_USERS=<contact-id-1>,<contact-id-2>
    +SIMPLEX_HOME_CHANNEL=<contact-id>
    +```
    +
    +| Variable | Required | Description |
    +|---|---|---|
    +| `SIMPLEX_WS_URL` | Yes | WebSocket URL of the simplex-chat daemon |
    +| `SIMPLEX_ALLOWED_USERS` | Recommended | Comma-separated contact IDs allowed to use the agent |
    +| `SIMPLEX_ALLOW_ALL_USERS` | Optional | Set `true` to allow every contact (use carefully) |
    +| `SIMPLEX_HOME_CHANNEL` | Optional | Default contact ID for cron job delivery |
    +| `SIMPLEX_HOME_CHANNEL_NAME` | Optional | Human label for the home channel |
    +
    +## Find your contact ID
    +
    +After starting the daemon, open a conversation with your agent contact. The contact ID will appear in session logs or via `hermes send_message action=list`.
    +
    +## Authorization
    +
    +By default **all contacts are denied**. You must either:
    +
    +1. Set `SIMPLEX_ALLOWED_USERS` to a comma-separated list of contact IDs, or
    +2. Use **DM pairing** — send any message to the bot and it will reply with a pairing code. Enter that code via `hermes gateway pair`.
    +
    +## Using SimpleX with cron jobs
    +
    +```python
    +cronjob(
    +    action="create",
    +    schedule="every 1h",
    +    deliver="simplex",          # uses SIMPLEX_HOME_CHANNEL
    +    prompt="Check for alerts and summarise."
    +)
    +```
    +
    +Or target a specific contact:
    +
    +```python
    +send_message(target="simplex:<contact-id>", message="Done!")
    +```
    +
    +## Privacy notes
    +
    +- SimpleX never reveals phone numbers or email addresses — contacts use opaque IDs
    +- The connection between Hermes and the daemon is local WebSocket (`ws://127.0.0.1:5225`) — no data leaves your machine
    +- Messages are end-to-end encrypted by the SimpleX protocol before reaching the daemon
    +
    +## Troubleshooting
    +
    +**"Cannot reach daemon"** — Ensure `simplex-chat -p 5225` is running and the port matches `SIMPLEX_WS_URL`.
    +
    +**"websockets not installed"** — Run `pip install websockets`.
    +
    +**Messages not received** — Check that the contact's ID is in `SIMPLEX_ALLOWED_USERS` or approve them via DM pairing.
    ```
  - `website/sidebars.ts`

    ```diff
    @@ -145,6 +145,7 @@ const sidebars: SidebarsConfig = {
             'user-guide/messaging/teams-meetings',
             'user-guide/messaging/msgraph-webhook',
             'user-guide/messaging/line',
    +        'user-guide/messaging/simplex',
             'user-guide/messaging/open-webui',
             'user-guide/messaging/webhooks',
           ],
    ```
- **#2558: feat: add SimpleX Chat gateway adapter (#2557)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#26232`

## Fix status

- Merged to main in PR #26232 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-15`.
- Subsystem: `gateway`
