Files
pheby-hermes-platform-adapter/docs
Pheby 53ffdb8aa2 feat: Pheby platform adapter/plugin for Hermes (protocol v1)
Third-party Hermes platform plugin serving the Pheby WebSocket+HTTPS
protocol for the native Android client, behind Caddy:

- Platform adapter (BasePlatformAdapter subclass) registered via the
  documented plugin system (plugins.platforms pattern, kind: platform)
- Multiple conversations mapped to Hermes sessions (authoritative state)
- Streaming chat via GatewayStreamConsumer edit path (message.delta)
- Structured tool events (never fake tool text in chat), incl.
  post_tool_call hook relay with Hermes tool_call_ids
- Native approval + clarification round-trips via tools.approval /
  tools.clarify_gateway primitives
- Attachment delivery: adapter-owned copies, opaque IDs, persistent
  metadata, 7-day retention + safe cleanup, authenticated HTTPS download
- Model + reasoning-effort query/change via Hermes picker data and
  session/global overrides
- Shared-secret auth (constant-time, lockout), size limits, path-
  traversal-proof attachment resolution, minimal health endpoint
- Protocol v1 spec with JSON examples (docs/PROTOCOL.md)
- Install/config/Caddy/security docs + discovered Hermes limitations
- 37 passing tests (auth, conversations, protocol, attachments incl.
  expiry/traversal, tool events, approvals, clarifications, cancel,
  reconnect re-sync, live WS smoke tests) — gateway-free fakes

No Hermes core modifications required.
2026-09-02 19:31:17 +00:00
..

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, format_tool_event → 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 — auth/pairing, tool approval, clarify, deliverables, streaming, cron delivery — because inbound messages are ordinary MessageEvents on a registered platform.
  • 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), PHEBY_ALLOWED_USERS, PHEBY_ALLOW_ALL_USERS.

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.
  • X-Forwarded-For is not used for auth decisions (the lockout key is the direct peer address — behind Caddy that is Caddy itself, so lockout is effectively global; that is acceptable for a single-user deployment and still stops brute force).
  • 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 delete is a documented approximation. Hermes's SessionStore has no public per-routing-key delete; Pheby deletes the authoritative transcript row (SessionDB.delete_session) and resets the routing entry, which yields the same user-visible behavior.
  4. Tool running → completed ID correlation. Start events get adapter-generated IDs; completion events carry Hermes's authoritative tool_call_id from the post_tool_call hook. The protocol documents correlating by (tool_name, conversation) when IDs differ. (Gateway tool-start events don't carry Hermes's call ID yet.)
  5. 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).
  6. Reconnect recovery is state-based, not event-replay. Missed events are recovered by re-opening conversations (authoritative history), not by replaying a server-side event log. This is the spec's preferred approach and keeps the protocol simple.

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

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.