12 KiB
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 - Original product spec:
docs/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:<conversation_id>; history/titles live in Hermesstate.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_SECRETgates 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-Foris 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/wsand downloads attachments fromhttps://pheby.example.com/attachments/{id}withAuthorization: Bearer <PHEBY_SECRET>.
How authentication works
- The client presents
PHEBY_SECRETonce in thehelloWS frame, and on every attachment HTTP request (Authorization: Bearer …,ApiKey …, orX-Pheby-Secret: …). - Comparison is constant-time (
hmac.compare_digest); failed hellos rate limit the source (5 failures / 60 s → lockout). /healthis 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
- The agent produces a file through Hermes's normal deliverable pipeline
(
MEDIA:tags →validate_media_delivery_path→ adapter send hooks). - 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. - The client gets
attachment.addedwith adownload_path; downloads are streamed over authenticated HTTPS.inline_imagemarks previewable images. - 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.
- 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);
/healthexposes 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.
- Session model change is not instant mid-run.
model.set/reasoning.setwrite the session override (or global config); the next turn picks it up. An in-flight run finishes on its current model. - 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. - 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 callreset_session(which would recreate the deleted chat). This is isolated inhermes_bridge.pybut may need adjustment after a Hermes SessionStore refactor. - Standalone cron delivery. Cron jobs targeting
phebyare delivered in-process with the gateway. Astandalone_sender_fnhook 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). - 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(handleready/ 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.eventcomponents keyed bytool_call_id.approval.request→ Approve/Deny buttons →approval.respond.clarify.request→ choice buttons + free text →clarify.respond.attachment.added→ inline preview wheninline_image, else download (authenticated GET) →run.cancelfor the stop button.models.list/current/set,reasoning.current/set.- Reconnect: backoff, re-hello, re-open visible conversations.