# Pheby — Hermes Platform Adapter/Plugin A private, third-party **Hermes Agent platform plugin** that serves the **Pheby protocol** — a WebSocket + HTTPS API designed for a native Android client (Kotlin/Compose). It exposes multiple Hermes conversations, streamed chat, structured tool events, native approval/clarification round-trips, model + reasoning-effort selection, and agent-generated attachments with 7-day retention. No Hermes core modifications. Installs into Hermes's supported user plugin location and uses the documented platform-adapter extension points (same mechanism as the bundled Telegram/Discord/ntfy adapters). - **Protocol spec (for the Kotlin client):** [`docs/PROTOCOL.md`](PROTOCOL.md) - **Original product spec:** [`docs/spec-source/`](spec-source/) --- ## Architecture (concise) ``` Android (Kotlin/Compose) │ HTTPS + WSS (shared secret) ▼ Caddy ── TLS termination, reverse proxy │ plain HTTP/WS ▼ Pheby plugin (in Hermes gateway process) ├─ aiohttp server: GET /ws, GET /attachments/{id}, GET /health ├─ protocol layer: JSON message types, auth, limits, errors ├─ PhebyAdapter (BasePlatformAdapter subclass) │ outbound: send/edit → chat events, tool hooks → tool.event, │ send_clarify → clarify.request, send_document/… → attachments │ inbound: WS messages → MessageEvent → gateway pipeline └─ hermes_bridge: sessions, approvals (tools.approval), clarifications (tools.clarify_gateway), models, reasoning ▼ Hermes Gateway (authoritative): agent loop, sessions/state.db, tools, skills, cron, deliverables ``` Key properties: - **Hermes is authoritative.** Conversations are Hermes sessions keyed `agent:main:pheby:dm:`; history/titles live in Hermes `state.db`. Pheby keeps only a thin, rebuildable name index. - **The full gateway pipeline works unchanged** — tool approval, clarify, deliverables, streaming, cron delivery — because inbound messages are ordinary `MessageEvent`s on a registered platform. The adapter marks its authenticated WebSocket as the upstream authorization boundary, so a valid Pheby secret is not rejected by Hermes's separate messaging- platform allowlist layer. - **Single-user, shared-secret.** One `PHEBY_SECRET` gates every WS connection and attachment download (constant-time compare). No accounts, no registration — but IDs and message shapes are multi-client friendly. --- ## Installation Requirements: Hermes Agent with its bundled venv (aiohttp is already a Hermes dependency — nothing extra to install). ### 1. Copy the plugin into HERMES_HOME ```bash # HERMES_HOME is ~/.hermes by default (or your profile dir) mkdir -p ~/.hermes/plugins cp -r plugin/pheby ~/.hermes/plugins/pheby ``` The plugin directory must contain `plugin.yaml`, `__init__.py`, and the `.py` modules. ### 2. Set the secret Generate a strong secret and put it in Hermes's env file: ```bash echo "PHEBY_SECRET=$(openssl rand -hex 32)" >> ~/.hermes/.env chmod 600 ~/.hermes/.env ``` ### 3. Enable it in config.yaml ```yaml plugins: enabled: - pheby # user plugins are opt-in (untrusted code gate) display: platforms: pheby: # The client receives structured tool.event records for its native pill. # Disable Hermes's generic text progress or every tool appears twice. tool_progress: off platforms: pheby: enabled: true extra: bind_host: "127.0.0.1" # default; safe behind local Caddy port: 8620 attachment_storage_dir: "" # default: /pheby-attachments attachment_retention_days: 7 debug: false # verbose protocol logging log_chat_content: false # debug logs include chat text (privacy) ``` Env vars override YAML: `PHEBY_SECRET`, `PHEBY_BIND_HOST`, `PHEBY_PORT`, `PHEBY_DEBUG`, `PHEBY_LOG_CHAT_CONTENT`. Optional: `PHEBY_HOME_CHANNEL` (conversation ID receiving cron deliveries). ### 4. Start Hermes with the gateway ```bash hermes gateway restart # or: hermes gateway run (foreground) hermes gateway status # should list pheby as connected ``` Logs: `~/.hermes/logs/gateway.log` (look for `[pheby]` lines: startup, connections, auth failures, attachment registration, cleanup). ### Smoke check ```bash curl -s http://127.0.0.1:8620/health # → {"status":"ok"} # WS: connect to /ws, send hello with your secret → "ready" ``` --- ## Caddy reverse proxy Terminate TLS at Caddy; Pheby stays plain HTTP on localhost. WebSockets and attachment downloads need no special config beyond the reverse proxy itself (Caddy handles WebSocket upgrades transparently): ```caddy # Caddyfile pheby.example.com { encode zstd gzip # WebSocket + API reverse_proxy /ws 127.0.0.1:8620 reverse_proxy /attachments/* 127.0.0.1:8620 reverse_proxy /health 127.0.0.1:8620 # Optionally restrict by source when on a public VPS: # @notlan not remote_ip 10.0.0.0/8 192.168.0.0/16 # respond @notlan 403 } ``` A single catch-all also works: ```caddy pheby.example.com { reverse_proxy 127.0.0.1:8620 } ``` Notes: - Caddy provides HTTPS + automatic certificates; the plugin never sees TLS. - Authentication always depends on the shared secret. For lockout accounting, `X-Forwarded-For` is accepted only when the direct peer is loopback (the documented local-Caddy setup). A directly exposed non-loopback client cannot spoof that header to evade rate limiting. - The Android client connects to `wss://pheby.example.com/ws` and downloads attachments from `https://pheby.example.com/attachments/{id}` with `Authorization: Bearer `. --- ## How authentication works - The client presents `PHEBY_SECRET` **once** in the `hello` WS frame, and on **every** attachment HTTP request (`Authorization: Bearer …`, `ApiKey …`, or `X-Pheby-Secret: …`). - Comparison is constant-time (`hmac.compare_digest`); failed hellos rate limit the source (5 failures / 60 s → lockout). - `/health` is the only unauthenticated route and leaks nothing. - The secret is **never** Hermes's API-server key — it is an independent credential stored in `~/.hermes/.env` (never in config.yaml history, never logged; debug logs redact it and it is excluded from protocol echo). - It is **not** a per-user identity: anyone with the secret *is* the user. Keep it secret; rotate by changing `.env` + restarting the gateway. --- ## Attachment delivery & expiration 1. The agent produces a file through Hermes's normal deliverable pipeline (`MEDIA:` tags → `validate_media_delivery_path` → adapter send hooks). 2. The adapter **copies** the file into adapter-owned storage (`/pheby-attachments/`), assigns an opaque 32-hex ID, and records metadata (filename, MIME, size, kind, conversation, timestamps) in a persistent JSON index. 3. The client gets `attachment.added` with a `download_path`; downloads are streamed over authenticated HTTPS. `inline_image` marks previewable images. 4. An hourly sweep deletes expired blobs (default 7 days) — **only** files the store registered, inside its own root. Hermes-owned originals elsewhere on disk are never touched. 5. Expired/unknown/malformed IDs all return the same minimal 404. Metadata persists across restarts, so valid attachments survive gateway restarts. Path traversal is impossible: IDs are validated, blobs are resolved from stored metadata inside the storage root, and every resolve re-checks containment. --- ## Debugging & logging Set `PHEBY_DEBUG=true` (env) or `extra.debug: true` (YAML) for verbose protocol logs (inbound/outbound types, connection lifecycle, cleanup counts). Debug logging **never** prints secrets, provider keys, or Authorization headers, and by default masks chat text (`PHEBY_LOG_CHAT_CONTENT=true` to include text during client development — privacy tradeoff, off by default). Tool **results** are deliberately not relayed (they can embed host paths); clients get structured status/duration. Full transcripts always remain available via `conversation.open` (Hermes's authoritative store). --- ## Security considerations - Treat the endpoint as access to a powerful agent with host tool access: bind to `127.0.0.1`, front with TLS, use a 256-bit secret. - No client-controllable filesystem paths exist anywhere in the protocol. - Inbound WS frames are size-capped (2 MiB) and strictly JSON-validated; chat text is length-capped (64k chars). - Lockout + constant-time comparison + auth timeout blunt brute force. - Errors are machine-readable codes; stack traces and internal paths never reach the client. - CORS is irrelevant (native client); `/health` exposes only `{"status":"ok"}`. --- ## Hermes-version limitations (discovered, not assumed) These are current Hermes behaviors the adapter documents rather than hacks around. None require core modifications; all are handled cleanly. 1. **Session model change is not instant mid-run.** `model.set` / `reasoning.set` write the session override (or global config); the next turn picks it up. An in-flight run finishes on its current model. 2. **Reasoning-effort capability metadata is partial.** Hermes knows *that* a model supports reasoning (models.dev) but has no per-provider enum of valid effort levels. Pheby exposes Hermes's canonical level set (`none, minimal, low, medium, high, xhigh, max, ultra`) and documents that an unsupported level surfaces as a provider error on the next turn. 3. **Conversation routing deletion uses a private Hermes detail.** Hermes's SessionStore has no public per-routing-key delete. Pheby deletes the authoritative transcript through `SessionDB.delete_session`, then removes the exact routing entry under SessionStore's own lock/save discipline. It does not call `reset_session` (which would recreate the deleted chat). This is isolated in `hermes_bridge.py` but may need adjustment after a Hermes SessionStore refactor. 4. **Standalone cron delivery.** Cron jobs targeting `pheby` are delivered in-process with the gateway. A `standalone_sender_fn` hook exists but cannot push to a WS server it isn't hosting; out-of-process cron delivery to Pheby is not supported (documented, fail-loud). 5. **Reconnect recovery is state-based, not event-replay.** Re-opening a conversation returns authoritative history plus unexpired attachments and the current run, tool, approval, and clarification snapshot. The server does not retain a replay log of every transient delta. --- ## Development ```bash python3 -m venv .venv && .venv/bin/pip install pytest pytest-asyncio aiohttp pyyaml requests .venv/bin/python -m pytest tests/ -o addopts= -q --asyncio-mode=auto ``` The 46 tests are gateway-free (fakes; real Hermes primitives exercised in-process where safe — no LLM calls). Two live smoke tests bind an ephemeral localhost port. ``` repo layout ├── plugin/pheby/ # the plugin (install this dir) │ ├── __init__.py # register(ctx): platform + hook registration │ ├── plugin.yaml # manifest (kind: platform) │ ├── adapter.py # BasePlatformAdapter subclass │ ├── server.py # aiohttp app + WS dispatch │ ├── ws_client.py # per-connection state │ ├── hermes_bridge.py # ALL Hermes-internal integration (defensive) │ ├── conversations.py # conversation ID ↔ session routing │ ├── attachments.py # attachment store + cleanup + auth helpers │ ├── protocol.py # message types, errors, limits │ └── config.py # env/YAML config resolution ├── tests/ └── docs/ # PROTOCOL.md, spec-source/ ``` ## Client implementation checklist (Kotlin/Compose) Minimum client surface: - WS connect + `hello` (handle `ready` / error / close 4401) + ping/pong. - `conversation.list/open/create/rename/delete` + `chat.send`. - Render state machine: `run.accepted`, `message.start`, `message.delta` (replace bubble text), `message.complete`, `run.finished`. - `tool.event` components keyed by `tool_call_id`. - `approval.request` → Approve/Deny buttons → `approval.respond`. - `clarify.request` → choice buttons + free text → `clarify.respond`. - `attachment.added` → inline preview when `inline_image`, else download (authenticated GET) → `run.cancel` for the stop button. - `models.list/current/set`, `reasoning.current/set`. - Reconnect: backoff, re-hello, re-open visible conversations.