- docs/install.md: new end-to-end guide for non-technical users (gateway install, app install, LAN/TLS/remote connection, push, options, troubleshooting); docs/setup.md now points to it - README: new 'Install the gateway' section; pairing section updated for HTTP transport (8791, QR scan on Android) - rename IRIS_WS_HOST -> IRIS_HTTP_HOST (clean rename, no compat fallback); drop dead DEFAULT_PORT=8790 - setup.py: advertise https:// in the printed/QR server URL when IRIS_HTTP_CERT is set - ws_probe.py/e2e.py: default --url http://127.0.0.1:8791, env IRIS_WS_URL -> IRIS_HTTP_URL, honor explicit port + https scheme - plugin.yaml: IRIS_HTTP_* env names, description no longer says 'WebSocket server' - docs 03/09/12/19: fix stale WS-era refs (ws_server.py cites, 8790 smoke test, WSS->HTTPS, 'HTTP fallback' reframed as the only transport) - AGENTS.md: symlink name android -> iris (matches actual install) - test: adapter reads IRIS_HTTP_HOST/CERT/KEY from env; legacy IRIS_WS_* names are not consulted (95/95 pass)
271 lines
13 KiB
Markdown
271 lines
13 KiB
Markdown
# 03 — Gateway Plugin (Python)
|
|
|
|
The plugin is a **community-style hermes platform plugin** named `iris`.
|
|
It follows the "Plugin Path" in `hermes-agent/gateway/platforms/ADDING_A_PLATFORM.md`
|
|
and the canonical example `hermes-agent/plugins/platforms/irc/adapter.py`.
|
|
**Zero hermes-core changes. Zero new Python dependencies** (`websockets` and
|
|
`httpx` are already core deps).
|
|
|
|
> Source map for every hermes integration point: see `15-hermes-reference.md`.
|
|
|
|
## 3.1 `plugin.yaml` (manifest)
|
|
|
|
```yaml
|
|
name: iris-platform
|
|
label: Iris
|
|
kind: platform
|
|
version: 0.1.0
|
|
description: >
|
|
Native Android / Desktop client gateway adapter for Hermes Agent.
|
|
Runs a WebSocket server inside the gateway; the app connects with a
|
|
pairing token. Supports streaming, reasoning, structured tool events,
|
|
channels/threads, media, FTS5 search, and FCM/ntfy push.
|
|
author: <you>
|
|
requires_env:
|
|
- name: IRIS_TOKEN
|
|
description: "Shared pairing token the app presents on connect"
|
|
prompt: "Iris pairing token"
|
|
password: true
|
|
optional_env:
|
|
- name: IRIS_HTTP_HOST
|
|
description: "HTTP bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
|
|
prompt: "HTTP host"
|
|
password: false
|
|
- name: IRIS_HTTP_PORT
|
|
description: "HTTP port (default 8791)"
|
|
prompt: "HTTP port"
|
|
password: false
|
|
- name: IRIS_HOME_CHANNEL
|
|
description: "Default chat id for cron/notification delivery (default default)"
|
|
prompt: "Home channel"
|
|
password: false
|
|
- name: IRIS_ALLOWED_USERS
|
|
description: "Comma-separated allowed device_ids (empty = token-only auth)"
|
|
prompt: "Allowed device ids"
|
|
password: false
|
|
- name: IRIS_ALLOW_ALL_USERS
|
|
description: "Allow any paired device (dev only)"
|
|
prompt: "Allow all devices? (true/false)"
|
|
password: false
|
|
- name: IRIS_PUSH_BACKEND
|
|
description: "Push backend: ntfy (default, keeps metadata off Google) or fcm"
|
|
prompt: "Push backend"
|
|
password: false
|
|
- name: IRIS_FCM_SERVICE_ACCOUNT
|
|
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
|
|
prompt: "FCM service account path"
|
|
password: true
|
|
- name: IRIS_FCM_SERVER_KEY
|
|
description: "Legacy FCM server key (fallback if no service account)"
|
|
prompt: "FCM server key"
|
|
password: true
|
|
- name: NTFY_TOPIC
|
|
description: "ntfy topic for push (when IRIS_PUSH_BACKEND=ntfy)"
|
|
prompt: "ntfy topic"
|
|
password: false
|
|
- name: NTFY_SERVER_URL
|
|
description: "ntfy server URL (default https://ntfy.sh)"
|
|
prompt: "ntfy server URL"
|
|
password: false
|
|
- name: IRIS_HTTP_CERT
|
|
description: "TLS cert path for HTTPS (optional)"
|
|
prompt: "HTTPS cert"
|
|
password: false
|
|
- name: IRIS_HTTP_KEY
|
|
description: "TLS key path for HTTPS (optional)"
|
|
prompt: "HTTPS key"
|
|
password: false
|
|
```
|
|
|
|
Behavioral (non-secret) settings live in `config.yaml` under
|
|
`gateway.platforms.iris.extra` (host, port, home_channel, outbox retention,
|
|
max upload bytes, tls). Secrets live in `.env`. (hermes policy: `.env` = secrets
|
|
only.)
|
|
|
|
## 3.2 `register(ctx)` entry point
|
|
|
|
```python
|
|
def register(ctx):
|
|
ctx.register_platform(
|
|
name="iris",
|
|
label="Iris",
|
|
adapter_factory=lambda cfg: IrisAdapter(cfg),
|
|
check_fn=check_requirements, # passive: websockets importable + token set
|
|
validate_config=validate_config, # host/port/token present
|
|
is_connected=is_connected,
|
|
required_env=["IRIS_TOKEN"],
|
|
install_hint="No extra packages needed (websockets + httpx are core deps)",
|
|
setup_fn=interactive_setup, # hermes gateway setup flow
|
|
env_enablement_fn=_env_enablement, # seed extra + home_channel from env
|
|
cron_deliver_env_var="IRIS_HOME_CHANNEL",
|
|
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
|
|
parse_target_ref_fn=_parse_target_ref, # "iris:<chat>[:<thread>]"
|
|
allowed_users_env="IRIS_ALLOWED_USERS",
|
|
allow_all_env="IRIS_ALLOW_ALL_USERS",
|
|
max_message_length=0, # 0 = no limit (WS has none)
|
|
emoji="📱",
|
|
pii_safe=False,
|
|
platform_hint=(
|
|
"You are chatting with the user through their native Iris app "
|
|
"(Android/Desktop). It renders Markdown, inline code, images, "
|
|
"audio and video, and shows your reasoning and tool activity. "
|
|
"Conversations are organized into channels and optional threads. "
|
|
"Keep formatting rich but readable."
|
|
),
|
|
)
|
|
```
|
|
|
|
Field reference (all from `PlatformEntry`, `gateway/platform_registry.py:63`):
|
|
`adapter_factory`, `check_fn`, `validate_config`, `is_connected`, `required_env`,
|
|
`install_hint`, `setup_fn`, `env_enablement_fn`, `apply_yaml_config_fn`,
|
|
`cron_deliver_env_var`, `parse_target_ref_fn`, `allowed_users_env`,
|
|
`allow_all_env`, `max_message_length`, `pii_safe`, `platform_hint`, `emoji`,
|
|
`ensure_deps_fn`.
|
|
|
|
- **`check_requirements()`** — passive probe: `import websockets` succeeds and
|
|
`IRIS_TOKEN` is set. Never installs.
|
|
- **`_env_enablement()`** — returns a dict seeding `PlatformConfig.extra`
|
|
(host/port/home_channel/push_backend) + a `home_channel` key
|
|
`{"chat_id": "default", "name": "Default"}` so `hermes gateway status`
|
|
and cron home-channel resolution work without instantiating the adapter.
|
|
- **`_parse_target_ref(ref)`** — the core strips the platform prefix first, so
|
|
`ref` is the direct chat id (e.g. `chan_7`, `default`) with an optional
|
|
`:t_<n>` thread suffix; friendly names resolve via the channel directory.
|
|
Returns `(chat_id, thread_id)` or `None`.
|
|
- **`interactive_setup()`** — prompts for token (or generates one), host/port,
|
|
push backend + credentials, prints a QR code (pairing) and the app URL.
|
|
|
|
## 3.3 `IrisAdapter(BasePlatformAdapter)`
|
|
|
|
Constructor: `super().__init__(config=config, platform=Platform("iris"))`.
|
|
Reads `config.extra` (env overrides win). Initializes: WS server (not started
|
|
until `connect()`), connection registry, outbox (SQLite under
|
|
`get_hermes_home()/"iris"`), push backend, pairing store, channel directory.
|
|
|
|
### Lifecycle
|
|
|
|
- **`connect(*, is_reconnect=False) -> bool`**
|
|
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("iris", key)`)
|
|
so two profiles can't bind the same port/identity.
|
|
- Start the `websockets` server on `host:port` (TLS if cert/key set).
|
|
- `_mark_connected()`; return True.
|
|
- **`disconnect()`**
|
|
- Stop server, close all device sockets, release lock, `_mark_disconnected()`.
|
|
|
|
### Inbound (app → agent)
|
|
|
|
- WS `message.send {text, reply_to?, media_refs?}` → build `SessionSource` via
|
|
`self.build_source(chat_id, chat_name, chat_type, user_id, user_name,
|
|
thread_id)` → build `MessageEvent(text=…, message_type=TEXT, source=…,
|
|
media_urls=[cached paths], media_types=[…], reply_to_message_id=…)` →
|
|
`await self.handle_message(event)`.
|
|
- Slash commands arrive as plain text starting with `/`; the gateway's command
|
|
pipeline resolves + dispatches them (no special handling needed).
|
|
- `picker.select {picker_id, value}` → route to the gateway-side resolvers
|
|
(model picker / choice picker / clarify / approval / slash-confirm) using the
|
|
shared callback-id conventions (`cl:<id>:<idx>`, `appr:<id>:<choice>`,
|
|
`sc:<choice>:<id>`).
|
|
- `channel.create` / `channel.rename` / `channel.set_default` → mutate the
|
|
channel directory (SQLite) + emit `channel.*` frames to all devices.
|
|
- `search {query, scope, chat_id?, thread_id?}` → `search.py` → `search.results`.
|
|
- `media.upload` (chunked) → `media.py` → `cache_*_from_bytes` → `media_ref`.
|
|
- `media.pull {media_id}` → stream cached bytes as binary frames.
|
|
- `fcm.register {token}` / `hello` → update device registry.
|
|
- `read.receipt {message_id}` → mark delivered/read (drives ✓✓), ack.
|
|
- `sync {cursor}` → `outbox.py` → replay frames since cursor.
|
|
|
|
### Outbound (agent → app)
|
|
|
|
- **`send(chat_id, content, reply_to=None, metadata=None) -> SendResult`**
|
|
- Split reasoning prefix (see `05-streaming.md`) → `reasoning` field.
|
|
- If **any** device is connected: broadcast `message` frame to all.
|
|
- Else (no live devices): append to **outbox** + fire **push** (FCM/ntfy).
|
|
- Return `SendResult(success=True, message_id=<id>)`.
|
|
- **`edit_message(chat_id, message_id, content)`** → `message.update` frame
|
|
(drives streaming). If no device, no-op (outbox holds the final `send`).
|
|
- **`send_typing(chat_id, metadata=None)`** → `typing` frame.
|
|
- **`get_chat_info(chat_id) -> dict`** → `{"name": <channel name>, "type": "dm"|"channel"}`
|
|
from the channel directory.
|
|
- **Media send** — `send_image / send_video / send_document / send_voice /
|
|
send_image_file / send_multiple_images`: stage the file in the media cache,
|
|
mint a `media_id`, emit `media.offer {media_id, mime, size, filename, kind}`,
|
|
serve bytes on `media.pull`. (Base-class `extract_media`/`extract_images`
|
|
already pull `MEDIA:`/image tags out of agent text and call these.)
|
|
- **Interactive pickers** — `send_model_picker(...)`, `send_choice_picker(...)`,
|
|
`send_clarify(...)`, `send_exec_approval(...)`, `send_slash_confirm(...)`:
|
|
emit `picker.model` / `picker.choice` / `picker.clarify` / `picker.approval` /
|
|
`picker.confirm` frames with options; store pending state keyed by
|
|
`picker_id`; resolve on `picker.select`.
|
|
- **`create_handoff_thread(chat_id, name)`** → create a thread id, register in
|
|
channel directory, return it (used by cron "continuable" threads).
|
|
|
|
### Streaming hooks
|
|
|
|
The main gateway drives delivery through the **legacy callback path**:
|
|
|
|
- `stream_delta_callback` → `GatewayStreamConsumer` → `send()` (first) +
|
|
`edit_message()` (updates) → `message.start` / `message.update`.
|
|
- `tool_progress_callback` → progress queue → `send_progress_messages` →
|
|
`send()` → `tool.*` frames (structured; classified in the adapter).
|
|
- `interim_assistant_callback` → consumer `on_commentary` → `send()` →
|
|
`commentary` frames.
|
|
|
|
The adapter tracks per-chat **turn state** (in-turn, current streaming
|
|
`message_id`, last tool index) to classify outbound `send()` calls into
|
|
`message` vs `tool.*` vs `commentary`. The exact classification markers are
|
|
verified empirically in M2 (see `13-testing.md`).
|
|
|
|
## 3.4 HTTP server (`http_server.py`)
|
|
|
|
- Library: **stdlib `http.server`** (`ThreadingHTTPServer` +
|
|
`BaseHTTPRequestHandler`) in a daemon thread; bridges into the gateway's
|
|
asyncio loop via `asyncio.run_coroutine_threadsafe`. Optional TLS via
|
|
`ssl.SSLContext` (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`). Full design:
|
|
`19-http-fallback-transport.md`.
|
|
- **Auth:** `Authorization: Bearer <token>` (constant-time `verify_token`)
|
|
- device allowlist via `X-Iris-Device`; `401` on failure.
|
|
- **Endpoints:** `GET /v1/health` (unauthenticated liveness),
|
|
`POST /v1/frame` (any JSON frame the protocol accepts),
|
|
`GET /v1/events?cursor=N` (SSE: outbox catch-up + live frames),
|
|
`GET /v1/poll?cursor=N` (long-poll fallback), `POST /v1/media` +
|
|
`GET /v1/media/{id}` (media upload/pull).
|
|
- **Routing:** `emit(chat_id, frame)` → broadcast to **all** connected
|
|
devices (no per-chat subscribe; single-user model). Global frames
|
|
(`channel.*`, `status`) also broadcast to all.
|
|
- **Limits:** 64 KiB request body cap, per-device token-bucket rate limit
|
|
(20/s, burst 40) → `429`; media uploads bounded by the per-upload total
|
|
cap. No CORS (app clients only).
|
|
|
|
## 3.5 State & storage (all under `get_hermes_home()/"iris"`)
|
|
|
|
> Use `get_hermes_home()` from `hermes_constants` for **all** paths (profile-safe).
|
|
> Never hardcode `~/.hermes`.
|
|
|
|
- `devices.db` — device registry (device_id, name, caps, fcm_token, ntfy_topic,
|
|
last_seen, created).
|
|
- `channels.db` — channel directory (chat_id, name, kind: default|channel|thread,
|
|
parent_chat_id, created, is_default).
|
|
- `outbox.db` — undelivered frames per chat_id + monotonic cursor.
|
|
- `media/` — inbound + outbound media cache (reuse hermes `cache_*_from_bytes`
|
|
dirs where possible).
|
|
|
|
## 3.6 Config resolution
|
|
|
|
- **Secrets (`.env`):** `IRIS_TOKEN`, `IRIS_FCM_SERVICE_ACCOUNT`,
|
|
`IRIS_FCM_SERVER_KEY`, `IRIS_HTTP_CERT/KEY`, `NTFY_TOPIC` (if secret).
|
|
- **Behavioral (`config.yaml` → `gateway.platforms.iris.extra`):** `host`,
|
|
`http_port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
|
|
`max_upload_bytes`, `http_cert`/`http_key`.
|
|
- Env vars override `config.yaml` (hermes convention). Read secrets with the
|
|
scope-aware `_get_scoped_secret` pattern (see `plugins/platforms/irc/adapter.py:42`)
|
|
so multiplexed profiles don't leak each other's tokens.
|
|
|
|
## 3.7 Failure & lifecycle safety
|
|
|
|
- HTTP server bind failure → non-fatal: log a warning, disable the HTTP leg,
|
|
show it in the inspector (the plugin keeps working for other platforms).
|
|
- All outbound sends are best-effort; a dead stream latches and the frame
|
|
falls to the outbox.
|
|
- `disconnect()` stops the HTTP server and closes streams cleanly.
|
|
- Token/PII redaction in all logs (hermes PII policy).
|