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.
This commit is contained in:
2026-09-02 19:31:17 +00:00
parent 31c16de665
commit 53ffdb8aa2
19 changed files with 4688 additions and 0 deletions
+408
View File
@@ -0,0 +1,408 @@
# Pheby Protocol v1 — Specification
JSON messages over WebSocket, plus one authenticated HTTPS endpoint for
attachment downloads. Every message (both directions) carries a `"type"`.
Client→server requests MAY carry a `"request_id"` (any client-chosen string);
the direct reply echoes it. Server→client events are broadcast to all
authenticated connections (single-user app — typically one client).
Timestamps (`"ts"`) are ISO-8601 UTC. All IDs are opaque strings — the client
never constructs meaning from them and never sees server filesystem paths.
## Handshake
Connect to `wss://<host>/ws` (behind Caddy; the plugin itself is plain
`ws://127.0.0.1:8620/ws`). The **first** client frame must be `hello` within
10 seconds, or the server closes the socket (`auth_timeout`).
### Client → Server `hello`
```json
{
"type": "hello",
"secret": "<PHEBY_SECRET>",
"protocol_version": 1,
"request_id": "optional"
}
```
### Server → Client `ready` (success)
```json
{ "type": "ready", "protocol_version": 1, "server": "pheby",
"ts": "2026-09-02T18:00:00+00:00" }
```
Failure: the server replies with an `error` event (`unauthorized`,
`auth_timeout`, or `version_mismatch`) and closes. After 5 failed hellos from
one source address within 60s, further connections are refused (lockout).
### Heartbeat
```json
→ { "type": "ping" }
← { "type": "pong", "ts": "..." }
```
The server also sends WebSocket protocol-level pings (aiohttp `heartbeat=30`).
## Errors
Every error uses one shape with a machine-readable code:
```json
{ "type": "error", "request_id": "r1",
"error": { "code": "conversation_not_found", "message": "Conversation not found" },
"ts": "..." }
```
Codes: `unauthorized`, `auth_timeout`, `version_mismatch`, `bad_request`,
`invalid_json`, `unknown_type`, `not_found`, `conversation_not_found`,
`approval_not_found`, `clarify_not_found`, `too_large`, `rate_limited`,
`internal_error`, `not_implemented`.
Limits: chat text ≤ 64,000 chars; inbound WS frame ≤ 2 MiB (violations get
`too_large`); conversation history fetch ≤ 500 messages.
---
## Conversations
### `conversation.list`
```json
→ { "type": "conversation.list", "request_id": "r1" }
← { "type": "conversation.snapshot", "request_id": "r1",
"conversations": [
{ "conversation_id": "9f1c…", "name": "Project X",
"session_id": "20260902_101112_ab12cd34", // Hermes session (may be null)
"last_active": "2026-09-02T17:44:01+00:00", // may be null
"source": "hermes" } ] }
```
### `conversation.create`
```json
→ { "type": "conversation.create", "name": "New chat", "request_id": "r2" }
← { "type": "conversation.created", "conversation_id": "a1b2…", "name": "New chat", "request_id": "r2" }
// plus, broadcast to all clients:
← { "type": "conversation.updated", "conversation_id": "a1b2…", "name": "New chat" }
```
`name` optional. Conversation IDs are server-generated 32-hex opaque strings.
### `conversation.open` — load history (reconnect recovery)
```json
→ { "type": "conversation.open", "conversation_id": "a1b2…", "limit": 200, "request_id": "r3" }
← { "type": "conversation.history", "conversation_id": "a1b2…", "request_id": "r3",
"messages": [
{ "message_id": "m12", "role": "user", "text": "hey", "ts": "…|null" },
{ "message_id": "m13", "role": "assistant", "text": "hi!", "ts": "…|null" } ] }
```
History is the authoritative Hermes transcript (`role` is always `user` or
`assistant`). On reconnect, re-open the last-open conversations and resume —
no client-side message cache is needed for correctness. Unknown conversation
→ `conversation_not_found` error.
### `conversation.rename`
```json
→ { "type": "conversation.rename", "conversation_id": "a1b2…", "name": "Renamed", "request_id": "r4" }
← { "type": "conversation.renamed", "conversation_id": "a1b2…", "name": "Renamed", "request_id": "r4" }
← { "type": "conversation.renamed", "conversation_id": "a1b2…", "name": "Renamed" } // broadcast
```
### `conversation.delete`
```json
→ { "type": "conversation.delete", "conversation_id": "a1b2…", "request_id": "r5" }
← { "type": "conversation.deleted", "conversation_id": "a1b2…", "request_id": "r5" }
```
Deletes the Hermes session transcript and the routing entry. Attachments
belonging to the conversation age out on their own 7-day schedule.
**Not supported by design:** message editing, per-message deletion,
regeneration, edit-and-resend. If you need to "undo", send a correction
message (the agent sees the whole transcript).
---
## Chat & streaming
### `chat.send`
```json
→ { "type": "chat.send", "conversation_id": "a1b2…", "text": "What's the weather?", "request_id": "r6" }
```
### Server → Client run lifecycle
```json
← { "type": "run.accepted", "conversation_id": "a1b2…", "run_id": "8c1f…", "request_id": "r6" }
← { "type": "message.start", "conversation_id": "a1b2…", "run_id": "8c1f…", "message_id": "draft-8c1f…" }
```
While the agent streams, the server pushes **cumulative** draft text (the
client can simply replace the bubble's text each time — no delta stitching):
```json
← { "type": "message.delta", "conversation_id": "a1b2…", "message_id": "draft-8c1f…",
"text": "It's currently 27°C…", "ts": "..." }
```
Completion (final text supersedes the draft — render the final, drop the
draft):
```json
← { "type": "message.complete", "conversation_id": "a1b2…",
"message_id": "draft-8c1f…", "text": "…full final answer…", "ts": "..." }
```
`message.complete` with `"kind": "notice"` is a gateway lifecycle/status
notice rather than conversation content — render or ignore.
Run end:
```json
← { "type": "run.finished", "conversation_id": "a1b2…", "run_id": "8c1f…",
"status": "completed" | "cancelled" | "failed" | "idle",
"error": "only on failure" }
```
State machine per assistant turn:
`run.accepted → message.start → (message.delta)* → message.complete → run.finished`.
A turn with no streaming skips `message.start`/`message.delta`. Never infer
state from text — use these events.
### `run.cancel` — stop an active run
```json
→ { "type": "run.cancel", "conversation_id": "a1b2…", "run_id": "8c1f…", "request_id": "r7" }
← { "type": "run.finished", "conversation_id": "a1b2…", "run_id": "8c1f…", "status": "cancelled" }
```
Cancellation uses Hermes's supported interrupt mechanism (agent interrupt +
run-generation invalidation) — the conversation stays consistent and
resumable. Cancelling with no active run returns `run.finished`
`status:"idle"`.
---
## Tool events (structured — never chat text)
Tool activity arrives as `tool.event` messages, completely separate from
`message.*` chat content. The client renders them as compact tool components
(ChatGPT-style) attached to the assistant turn.
```json
{ "type": "tool.event",
"conversation_id": "a1b2…",
"tool_call_id": "t-1a2b3c4d5e6f",
"tool_name": "web_search",
"status": "running" | "completed" | "failed",
"description": "cats — short preview from the agent (may be null)",
"args_redacted": { "query": "cats" }, // only on "running"; secret-looking keys redacted
"duration_ms": 1234, // only on completion/failure (may be null)
"error": "only on failed, truncated", // only on "failed"
"ts": "..." }
```
Correlate `running` → `completed`/`failed` by `tool_call_id`. Note: the
running event's ID comes from the adapter and the completion event from
Hermes's `post_tool_call` hook; when they differ, correlate by
`(tool_name, conversation)` as a fallback and prefer the completion event's
ID going forward.
No fake "Searching the web…" text is ever injected into `message.*` events.
---
## Approvals
When Hermes pauses for a human decision on a dangerous action:
```json
{ "type": "approval.request",
"approval_id": "3d4e5f6070a1",
"session_key": "agent:main:pheby:dm:a1b2…",
"command": "rm -rf /tmp/build-output",
"description": "Destructive shell command (rm -rf)",
"choices": ["once", "session", "always", "deny"],
"ts": "..." }
```
Respond:
```json
→ { "type": "approval.respond", "approval_id": "3d4e5f6070a1",
"choice": "once" | "session" | "always" | "deny",
"reason": "optional free text with deny", "request_id": "r8" }
← { "type": "approval.resolved", "approval_id": "3d4e5f6070a1", "choice": "once", "request_id": "r8" }
// broadcast confirmation (also informs other tabs):
← { "type": "approval.resolved", "approval_id": "3d4e5f6070a1", "choice": "once", "accepted": true }
```
Choices map to Hermes semantics: `once` (approve this action), `session`
(approve pattern for this conversation), `always` (also persist), `deny`
(decline; the agent is told NOT to retry). Unknown/stale ID →
`approval_not_found` error. Hermes itself fails the approval closed after its
own timeout, so a silently-closed socket can't leave a zombie gate.
---
## Clarifications / choices
```json
{ "type": "clarify.request",
"clarify_id": "c1a2b3d4e5",
"session_key": "agent:main:pheby:dm:a1b2…",
"question": "Deploy to staging or production?",
"choices": ["staging", "production"], // null ⇒ free text only
"allow_free_text": true,
"ts": "..." }
```
Respond (either a choice value or free text):
```json
→ { "type": "clarify.respond", "clarify_id": "c1a2b3d4e5", "response": "production", "request_id": "r9" }
← { "type": "clarify.resolved", "clarify_id": "c1a2b3d4e5", "request_id": "r9" }
← { "type": "clarify.resolved", "clarify_id": "c1a2b3d4e5", "accepted": true } // broadcast
```
`accepted:false` on the broadcast means Hermes had already resolved/timed out
the prompt. Always render an "Other" affordance — Hermes clarifications
accept free text.
---
## Attachments (agent → client deliverables)
When the agent produces a file (image, document, audio, video… via Hermes's
normal `MEDIA:` deliverable pipeline), the server copies it into
adapter-managed storage and broadcasts:
```json
{ "type": "attachment.added",
"conversation_id": "a1b2…",
"attachment": {
"attachment_id": "e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
"filename": "report.pdf",
"mime_type": "application/pdf",
"size": 48213,
"kind": "image" | "voice" | "video" | "audio" | "document",
"inline_image": false,
"conversation_id": "a1b2…",
"message_id": null,
"created_at": "2026-09-02T18:30:00+00:00",
"expires_at": "2026-09-09T18:30:00+00:00", // null when retention=0
"download_path": "/attachments/e5f6…" },
"ts": "..." }
```
Download over **HTTPS** (authenticated — same `PHEBY_SECRET`):
```
GET {download_path}
Authorization: Bearer <PHEBY_SECRET> (or ApiKey <secret>, or X-Pheby-Secret: <secret>)
```
* `inline_image: true` → `kind == "image"`, safe for an inline preview
(`BitmapFactory` / `AsyncImage` with the same authenticated GET).
* Everything else: download and open as an Android document.
* Unknown / expired / malformed ID → `404` with
`{"error":{"code":"not_found","message":"Attachment unavailable"}}` —
no implementation details.
* **The client can never request arbitrary files** — only registered
attachment IDs resolve.
* **Retention:** adapter copies expire after 7 days (configurable) and are
deleted by an hourly cleanup. Original files the agent produced elsewhere
on the host are never touched. `expires_at` tells the client when to stop
offering the download.
---
## Models
### List providers + models
```json
→ { "type": "models.list", "request_id": "r10" }
← { "type": "models.snapshot", "request_id": "r10",
"providers": [
{ "slug": "openrouter", "name": "OpenRouter", "is_current": true,
"models": ["z-ai/glm-5.3-flash", "anthropic/claude-sonnet-4", "…"],
"total_models": 42 } ],
"current_model": "z-ai/glm-5.3-flash",
"current_provider": "openrouter",
"supported_reasoning_efforts": ["minimal","low","medium","high","xhigh","max","ultra"],
"ts": "..." }
```
Lists come from Hermes's own credential-aware picker data — nothing is
hardcoded. Models are exactly what the configured providers expose.
### Read / change current model
```json
→ { "type": "models.current", "request_id": "r11" }
← { "type": "model.current", "request_id": "r11", "model": "z-ai/glm-5.3-flash", "provider": "openrouter", "ts": "..." }
→ { "type": "model.set", "model": "anthropic/claude-sonnet-4",
"provider": "anthropic", // optional
"conversation_id": "a1b2…", // present ⇒ session-scoped override
"request_id": "r12" }
← { "type": "model.changed", "model": "anthropic/claude-sonnet-4",
"provider": "anthropic", "scope": "conversation" | "global", "request_id": "r12" }
// plus broadcast of model.changed (without request_id) to all clients
```
Omit `conversation_id` ⇒ the change is persisted globally (Hermes
`model.default`). A `model.changed` with `scope:"global"` tells every open
conversation the default moved.
---
## Reasoning effort
```json
→ { "type": "reasoning.current", "request_id": "r13" }
← { "type": "reasoning.snapshot", "request_id": "r13",
"effort": "medium", // current effective effort (may be null = provider default)
"enabled": true, // false ⇒ thinking disabled
"supported_efforts": ["none","minimal","low","medium","high","xhigh","max","ultra"],
"ts": "..." }
→ { "type": "reasoning.set", "effort": "high", "conversation_id": "a1b2…", "request_id": "r14" }
← { "type": "reasoning.changed", "effort": "high", "scope": "conversation", "request_id": "r14" }
```
`effort: "none"` disables thinking. Invalid values → `bad_request`. Scope
rules mirror `model.set` (with `conversation_id` ⇒ session override; without ⇒
global `agent.reasoning_effort`). **Capability note:** Hermes knows *whether*
a model supports reasoning (models.dev metadata) but does not expose a
per-provider enum of valid effort values; the listed levels are Hermes's
canonical set — unsupported levels on a given provider surface as a provider
error on the next turn, not at set time. This is a documented Hermes
limitation, not a Pheby guess.
---
## Unsolicited messages & reconnect behavior
The WebSocket stays connected; any Hermes-originated output destined for the
Pheby platform (scheduled/cron deliveries, background completions,
notifications) is pushed as normal `message.*` / `run.*` events even when it
is not a reply to your last request. Reconnection procedure for clients:
1. Reconnect WS, redo `hello`.
2. Re-`conversation.open` the conversations you show; replace local state
with `conversation.history` (authoritative).
3. Re-`models.current` / `reasoning.current` if those views are visible.
4. Live events continue from there.
No external push service exists (no FCM); Android notification behavior is
the client's responsibility while the socket is down.
+309
View File
@@ -0,0 +1,309 @@
# 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, 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 `MessageEvent`s 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
```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)
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
```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.
- `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
```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
```
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.
@@ -0,0 +1,274 @@
I want you to build a private custom Hermes Agent platform adapter/plugin called Pheby.
Before implementing anything, inspect the current Hermes Agent documentation and current Hermes source code, especially its platform adapter/plugin APIs, Gateway architecture, Telegram/Discord adapters, conversation/session handling, tool-call events, clarification/approval mechanisms, model selection, reasoning effort, generated-file/deliverable handling, and plugin loading system. Do not rely on assumptions from older Hermes versions. The implementation must target the currently installed/current upstream Hermes architecture.
The Pheby adapter will serve a native Android application written in Kotlin with Jetpack Compose. For this task, build only the Hermes-side plugin and its protocol/API. Do not build the Android application.
The plugin must be a clean third-party Hermes plugin/platform adapter. Do not modify Hermes core files, monkey-patch Hermes, fork Hermes, or rely on hacks that will make Hermes upgrades difficult. It should install into Hermes's supported user plugin location and use documented extension points.
Overall architecture
Pheby is a single-user private platform.
The adapter should expose a network service intended to sit behind Caddy and be reachable over HTTPS/WSS from the public internet.
Use:
• WebSocket for realtime bidirectional chat/events.
• Normal HTTPS endpoints for downloading generated attachments.
• A shared secret/API-key-style credential for authentication.
• Hermes/Gateway functionality underneath rather than reimplementing the agent loop.
Assume Caddy handles TLS. The Pheby service itself can listen on localhost/plain HTTP and WebSocket.
Do not expose or reuse Hermes's master API-server key as the Pheby client credential.
The Pheby credential should be configurable through an environment variable and every WebSocket connection and HTTP attachment request must require authentication.
This is a single-user application. Do not build account registration, users, roles, password recovery, OAuth, etc. However, avoid unnecessarily designing the protocol in a way that makes future multi-client support impossible.
Conversations
Pheby must expose multiple Hermes conversations.
The Android client needs to be able to:
• List conversations.
• Open/load a conversation and its message history.
• Create a new conversation.
• Rename a conversation.
• Delete a conversation.
Hermes should remain the authoritative source of conversation/session state.
The protocol should provide stable conversation IDs.
When Pheby reconnects, it must be able to retrieve existing conversations and their messages rather than treating the connection as a new chat.
Do not implement message editing, individual message deletion, regeneration, or edit-and-resend.
Sending messages and streaming responses
The client must be able to send a text message into a selected conversation.
Use whichever response-streaming mechanism integrates most cleanly with Hermes. Token-level streaming is not a requirement. Chunk-level streaming is perfectly acceptable if it substantially simplifies the adapter.
The protocol should distinguish between:
• A new assistant message.
• Incremental content being appended to an assistant message.
• A completed assistant message.
• A failed/cancelled assistant run.
The UI should never have to infer these states by parsing arbitrary text.
The client must be able to stop/cancel an active Hermes task/run.
Stopping should use Hermes's supported cancellation/interruption mechanism rather than killing threads/processes or corrupting the conversation.
Tool calls
Tool execution must be represented as structured events, separate from the normal textual chat content.
This is important.
Pheby will display tool activity similarly to ChatGPT: tool calls can appear as compact UI components associated with an assistant turn without cluttering the actual chat transcript.
Do not inject fake text such as:
"Searching the web..." "Running command..." "Tool completed..."
into the assistant's message merely to represent activity.
Expose structured information for tool activity where Hermes makes it available, including at minimum:
• Unique tool-call/event ID.
• Conversation/run association.
• Tool name/type.
• Human-readable description if available.
• Start/running state.
• Completion state.
• Failure state.
• Result/summary information that is safe and appropriate to expose.
Preserve enough raw structured information that the Android client can create richer custom UI later, but do not expose secrets or internal credentials.
If Hermes provides nested/sub-agent/tool execution events, preserve their relationship where practical.
Approvals
Pheby must support Hermes's human approval system.
When Hermes pauses because an action requires approval, send a structured WebSocket event to Pheby containing enough information to render native:
Approve Deny
controls.
The event should include:
• Approval/request ID.
• Conversation/run association.
• Human-readable description.
• Relevant action/tool information.
• Any choices/actions Hermes permits.
Pheby must be able to submit an approval or denial and have the existing Hermes run continue appropriately.
Do not simulate approval through ordinary user chat messages if Hermes exposes a proper approval mechanism.
Clarifications / choices
Pheby must also support Hermes's interactive clarification/choice functionality.
If Hermes asks the user to choose between several structured options, expose the request as structured data over WebSocket so the Android app can render native buttons/options.
Provide:
• Clarification ID.
• Prompt/question.
• Available choices.
• Choice IDs/values.
• Whether free-text input is permitted, if Hermes supports that distinction.
• Conversation/run association.
The client must be able to submit the selected choice or clarification response so Hermes can continue the same pending operation.
Use Hermes's existing clarification/elicitation mechanism rather than inventing a parallel agent workflow.
Generated attachments / Deliverable Mode
This is a critical requirement.
Hermes must be able to generate files and send them to Pheby in the same general spirit as Telegram/Discord deliverables.
Pheby does not need to upload arbitrary files to Hermes.
Agent → Pheby attachments are required.
Reuse Hermes's existing Deliverable Mode/media detection and gateway abstractions wherever possible rather than recreating filename parsing from scratch.
Support arbitrary document/file types that Hermes's deliverable system supports.
At minimum represent:
• Attachment ID.
• Filename.
• MIME type.
• File size.
• Download endpoint/identifier.
• Associated conversation/message.
• Whether it is suitable for inline image preview.
Images should be identifiable as images so Pheby can display an inline preview.
Other files should be downloadable/openable as normal Android documents.
Do NOT expose arbitrary server filesystem paths to the client.
Do NOT provide an API that lets the client request arbitrary filesystem files.
Map generated files to opaque attachment IDs and serve only explicitly registered Hermes deliverables.
Attachment downloads must require the Pheby authentication credential.
Use normal HTTP streaming/downloads rather than transferring large files through the WebSocket.
Attachment cleanup
Generated Pheby attachment files should expire after 7 days.
Implement safe cleanup of expired adapter-managed attachments.
Cleanup must never delete unrelated Hermes/user files.
If Hermes's deliverable mechanism points to files Hermes owns elsewhere, do not blindly delete their originals. Prefer an adapter-managed attachment storage/copy/link strategy where the ownership and lifecycle are unambiguous.
Persist enough attachment metadata that restarting Hermes/Pheby does not immediately make all valid attachments inaccessible.
Expired attachment IDs should cleanly return an appropriate HTTP error rather than exposing implementation details.
Models
The Android client needs to query and change the active Hermes model.
Expose the models/providers Hermes currently makes available through its supported configuration/API.
Do not hardcode model names.
The protocol should expose useful model metadata when Hermes provides it.
The selected model must be changeable through Pheby using Hermes's supported mechanisms.
Reasoning effort
Pheby must expose and allow changing the model's reasoning effort where supported by the active provider/model.
Do not assume every model supports the same reasoning settings.
Expose supported values/capabilities dynamically where Hermes makes that information available.
If Hermes does not currently provide perfect capability metadata, implement the cleanest documented approach and clearly document the limitation instead of hardcoding fragile assumptions.
Skills and toolsets
Pheby does not need to enable, disable, configure, or manage Hermes skills/toolsets.
Normal Hermes tools and configured skills must continue functioning through the agent, but no management UI API is required.
Voice and user file uploads
Do not implement:
• Voice messages.
• Speech-to-text.
• Text-to-speech.
• Arbitrary user file uploads.
Design the protocol cleanly enough that additional event/content types could be added later without breaking existing clients, but don't spend significant implementation effort on features outside this scope.
Realtime / unsolicited messages
The WebSocket is intended to remain connected while Pheby is running.
The adapter must be able to push messages/events originating from Hermes even when they were not an immediate response to the most recent Pheby request. For example, if Hermes Gateway produces a scheduled/automated message destined for the Pheby platform, the connected client should receive it.
Do not implement Firebase Cloud Messaging or another external push-notification service.
Android notification behavior is the client's responsibility.
Make reconnection resilient. The protocol should make it possible for the client to recover messages/events it missed during a temporary WebSocket disconnect, preferably by re-syncing authoritative conversation state rather than requiring a perfectly uninterrupted socket.
Protocol
Design a small, explicit and versioned Pheby protocol.
Prefer JSON messages over WebSocket.
Every event should contain a clear type and whatever IDs are needed to associate it with:
• Conversation.
• Message.
• Run.
• Tool call.
• Approval.
• Clarification.
• Attachment.
Avoid making the Android client parse prose to determine application state.
Use stable opaque IDs.
Include an initial handshake/protocol-version exchange if useful.
Provide clean error responses/events with machine-readable error codes and human-readable messages.
Consider heartbeat/ping handling and clean reconnect behavior.
Do not build unnecessary complexity such as a custom binary protocol.
Document every client → server and server → client message type with JSON examples.
The protocol specification is part of the deliverable.
Configuration
Use Hermes-compatible configuration conventions.
Secrets should use environment variables.
Non-secret behavioral configuration may use Hermes/plugin YAML configuration where appropriate.
At minimum configuration should cover:
• Enabled/disabled.
• Bind host.
• Port.
• Authentication secret.
• Attachment storage location if configurable.
• Attachment retention period, defaulting to 7 days.
• Verbose/debug protocol logging.
The default bind host should be safe for use behind a local Caddy reverse proxy rather than listening publicly on every interface without explicit configuration.
Logging
Provide normal useful logging for:
• Startup/shutdown.
• Connections/disconnections.
• Authentication failures.
• Conversation routing errors.
• Hermes agent errors.
• Attachment handling.
• Cleanup.
• Approval/clarification lifecycle errors.
Include a configurable verbose/debug logging mode useful while developing the Android client.
Debug logging must still avoid printing:
• Authentication secrets.
• Provider API keys.
• Sensitive authorization headers.
Be thoughtful about whether raw chat content or tool results should appear in verbose logs; default to protecting user content unless explicitly configured otherwise.
Security requirements
Treat this adapter as access to a powerful personal Hermes agent capable of using tools on the host.
Do not trust client-provided filesystem paths.
Do not expose arbitrary filesystem downloads.
Prevent path traversal.
Authenticate all non-health-check functionality.
Use constant-time secret comparison where appropriate.
Validate message sizes and input structure.
Apply reasonable limits to avoid trivially exhausting server memory with malformed WebSocket messages.
Do not leak stack traces/internal paths to remote clients.
Design it to be safely reverse-proxied through Caddy over HTTPS/WSS.
CORS is not important because the intended client is native Android.
If HTTP health-check endpoints are included, they should expose minimal information.
Plugin quality
Keep the code maintainable and idiomatic for the existing Hermes codebase.
Reuse Hermes abstractions instead of copying large portions of gateway logic.
Use async code consistently with Hermes where appropriate.
Keep networking, Hermes adapter integration, attachment management, authentication, and protocol serialization separated enough to be understandable and testable.
Add type hints.
Add useful docstrings/comments where behavior is non-obvious.
Avoid unnecessary dependencies. Prefer libraries Hermes already depends on when practical.
Tests
Include tests for important behavior, particularly:
• Authentication success/failure.
• Conversation operations.
• Protocol serialization/parsing.
• Attachment registration and secure download.
• Rejection of arbitrary/path-traversal file access.
• Seven-day attachment expiration/cleanup.
• Tool-call event translation.
• Approval round trip.
• Clarification/choice round trip.
• Cancellation of active runs.
• WebSocket reconnect/recovery behavior where practical.
Mock the Hermes agent/gateway where appropriate rather than making tests consume paid LLM API calls.
Documentation
Create documentation explaining:
• Installation into Hermes.
• Required environment variables.
• Optional configuration.
• Starting Hermes with Pheby enabled.
• Example Caddy reverse-proxy configuration supporting WebSockets and attachment downloads.
• How authentication works.
• Pheby protocol specification.
• Conversation operations.
• Chat/message flow.
• Tool events.
• Approvals.
• Clarifications.
• Cancellation.
• Model selection.
• Reasoning effort.
• Attachment delivery.
• Attachment expiration.
• Reconnection behavior.
• Debugging/logging.
• Security considerations.
Include enough examples that I can implement the Kotlin/Compose client from the protocol documentation later.
Implementation process
Before writing the implementation, inspect the actual current Hermes abstractions and identify how the existing first-party platform adapters accomplish:
• registration,
• inbound messages,
• outbound messages,
• conversations/sessions,
• tool progress,
• deliverables,
• approvals,
• clarification prompts,
• cancellation,
• unsolicited Gateway messages.
Prefer adapting those existing mechanisms over designing parallel ones.
If one of my requirements cannot be implemented cleanly through Hermes's public plugin/adapter APIs, do not modify Hermes core to force it.
Instead:
1. Explain exactly what Hermes currently exposes.
2. Explain what limitation prevents the requested behavior.
3. Implement the cleanest supported approximation if one exists.
4. Keep the protocol extensible so the proper feature can be added later if Hermes exposes it.
Do not silently omit required functionality.
At the end, give me:
• The complete plugin source.
• Installation/configuration files.
• Tests.
• Protocol documentation.
• Caddy example.
• A concise explanation of the architecture.
• Any Hermes-version-specific limitations you discovered.
• A list of the key protocol events/endpoints that the eventual Kotlin client needs to implement.
The resulting Pheby adapter should require no Hermes core modifications and should survive normal Hermes upgrades as well as can reasonably be expected from its documented plugin API.