Files
pheby-hermes-platform-adapter/docs

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).


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:<conversation_id>; 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 MessageEvents 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

# 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:

echo "PHEBY_SECRET=$(openssl rand -hex 32)" >> ~/.hermes/.env
chmod 600 ~/.hermes/.env

3. Enable it in config.yaml

plugins:
  enabled:
    - pheby            # user plugins are opt-in (untrusted code gate)

platforms:
  pheby:
    enabled: true
    extra:
      bind_host: "127.0.0.1"     # default; safe behind local Caddy
      port: 8620
      attachment_storage_dir: "" # default: <HERMES_HOME>/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

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

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):

# 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:

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 <PHEBY_SECRET>.

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 (<HERMES_HOME>/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

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.