M0: toolchain, monorepo scaffold, gateway plugin skeleton, CMP app

- gateway-plugin/: android platform plugin (plugin.yaml + adapter.py
  register(ctx) + no-op AndroidAdapter) + stub modules for M1-M5
- app/: Compose Multiplatform project (shared KMP + androidApp +
  desktopApp) with Gradle wrapper; builds :androidApp:assembleDebug
  and :desktopApp:compileKotlin
- scripts/guard_hermes_agent.sh + pre-commit hook: fail if hermes-agent/
  is staged (read-only reference, never committed)
- .gitignore excludes hermes-agent/; docs/ reference library
This commit is contained in:
ARIA committed 2026-08-19 11:27:02 +02:00
commit 59acf66c89
49 files changed
+3950

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
# 00 — Overview
## Vision
A **native, Telegram-quality chat experience** for a personal hermes-agent:
install an app on your phone (and a desktop app on your PC), pair it to your
running `hermes gateway`, and talk to your agent with streaming replies,
visible reasoning, structured tool activity, channels/threads, media, search,
and push notifications — with cron jobs able to post into any channel you
create.
## Goals
- **Native feel.** Real Android app (Kotlin/Compose), not a WebView. Desktop
app that is the same app, resized for a big screen.
- **First-class gateway citizen.** The app is a hermes *messaging platform*, so
everything the gateway already does "just works": slash commands, cron
delivery, `send_message` routing, coexistence with Telegram/Discord/etc.
- **Full agent transparency.** Streaming text, reasoning shown *before* the
answer, structured tool events (the app chooses how much to show), and
intermediate assistant beats.
- **Organized by default.** A default chat with optional threads, plus
user-created channels that cron jobs can target.
- **Reachable anywhere.** Live over a WebSocket; background push via FCM
(primary) or ntfy (fallback).
## In scope (v1)
Everything in the feature checklist below.
## Out of scope / stretch (v1)
- **Standalone-cron delivery while the gateway process is fully down** —
best-effort FCM/ntfy only (the outbox is served by the running gateway).
- **Multi-user / group chat** — this is a *personal* 1-user agent.
- **End-to-end encryption** — transport security (WSS) only.
- **iOS** — Android + Desktop only (the protocol is transport-agnostic, so an
iOS client is a future port, not a v1 goal).
## Feature checklist → where it's handled
| Requirement | Gateway plugin | App |
|---|---|---|
| Input box, auto-grow (max height) | — | Compose `TextField` + bounded `heightIn` |
| Menu button → all slash commands | Dispatches `/…`; serves command catalog | Bottom-sheet menu + `/` autocomplete |
| Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing |
| Reasoning shown before message | Captures + splits reasoning | Collapsible "Reasoning" block above message |
| Intermediate messages | Forwards `Commentary` events | Distinct dimmed bubble |
| Threading + channels; default chat; user channels for cron | `chat_id`/`thread_id` model; cron `deliver=android:<chat>[:<thread>]` | Channel list, thread toggle, "new channel" |
| Search ("everywhere" / "this chat/channel") | FTS5 session search bridge | Search UI + scope toggle |
| Attach media (music/video/images/docs) | Inbound cache; outbound `send_*` | Pickers + chunked upload + preview |
| Push notifications | FCM (primary) / ntfy (fallback) | FCM token / ntfy topic + notification service |
| Live playback of AI-sent music/video | Serves media bytes over WS | ExoPlayer inline player |
## Locked decisions (from planning)
| Decision | Choice |
|---|---|
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
| Push backend | **Both** — FCM primary, ntfy fallback (`ANDROID_PUSH_BACKEND`) |
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) |
| Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) |
## Disclaimers (hard rules)
1. **`hermes-agent/` is a read-only research reference.** It lives next to this
folder for study only. It is **git-ignored** and must **never** be committed,
pushed, or included in any artifact. Our plugin is *installed* into a live
hermes home (`~/.hermes/plugins/android`); we never edit hermes core files.
2. **ADB is available and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S,
Android 10 / API 29). Use `adb install` / `adb logcat` / `adb shell am start`
to install, launch, and debug the app on-device throughout the build.
## Verified environment state (2026-08-19)
| Item | State |
|---|---|
| OS | CachyOS (Arch-based), `pacman` present |
| JDK | **Not installed** → Milestone M0 (`pacman -S jdk17-openjdk`) |
| Android SDK | **Not installed** → M0 (cmdline-tools + sdkmanager) |
| Gradle | Via project wrapper (`gradlew`), no system install |
| ADB | Installed; device `a5ca2a4b` (MIX 2S, API 29) connected |
| Python | 3.14.7; `uv` 0.12.3 present |
| hermes venv | **Not created** → M0 (`cd hermes-agent && uv sync`) |
| hermes core deps | `websockets==15.0.1` and `httpx` are **core** deps → plugin needs **zero new Python deps** |
| Disk / RAM | 522 GB free / 62 GB RAM — ample |
## Naming
- Product/effort name: **Iris × Hermes** (folder `iris_x_hermes`).
- hermes platform name: **`android`** (the plugin registers `Platform("android")`).
- WS default port: **8790** (configurable).
- Default chat id: **`android:default`** (the home channel).
+106
View File
@@ -0,0 +1,106 @@
# 01 — Architecture
## System diagram
```
┌────────────────────────────── User's machine (home server / PC) ─────────────────────────────┐
│ │
│ hermes gateway (ONE process) │
│ ┌──────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Agent core (run_agent.py) ── sessions (SQLite + FTS5) ── cron scheduler │ │
│ │ │ │ │
│ │ ▼ legacy stream callbacks (delta / tool-progress / commentary) │ │
│ │ ┌──────────────────────────────┐ ┌────────────────────────────────┐ │ │
│ │ │ android PLATFORM PLUGIN │◄───────►│ WebSocket SERVER (websockets) │ │ │
│ │ │ AndroidAdapter │ JSON │ ws://host:8790/ws │ │ │
│ │ │ (BasePlatformAdapter) │ frames │ + media + FCM-token + pairing │ │ │
│ │ │ • send / edit / stream │ └───────────────┬────────────────┘ │ │
│ │ │ • media cache │ │ WSS │ │
│ │ │ • outbox (SQLite) │ │ │ │
│ │ │ • push (FCM / ntfy) │─────────────────────────┼──────────┐ │ │
│ │ └──────────────────────────────┘ │ │ │ │
│ └───────────────────────────────────────────────────────────┼──────────┼───────────┘ │
└───────────────────────────────────────────────────────────────┼──────────┼───────────────────────┘
│ │ push (FCM HTTP v1 / ntfy)
┌──────────────────┼──────────▼─────────┐
│ │ Google FCM cloud │
▼ │ │ │
┌────────────────────────┐ │ ▼ │
│ ANDROID APP │◄──┴── (wake) ┌──────────┐
│ (Kotlin / Compose) │ WSS │ PHONE │
│ • WS client (OkHttp) │◄───────────►│ MIX 2S │
│ • ExoPlayer │ │ (API 29) │
│ • FCM service │ └──────────┘
└────────────────────────┘
DESKTOP APP (Compose Multiplatform)
• same shared code, WSS to same server
• tray + OS notifications (no FCM), big-screen two-pane
```
## Process model
- **One `hermes gateway` process** hosts the agent core, the session store, the
cron scheduler, *and* our `android` platform plugin. The plugin's WebSocket
server runs on the gateway's asyncio loop (started in `AndroidAdapter.connect()`).
- **The app is a client.** It *initiates* the WS connection to the gateway
(outbound), so no inbound port is needed on the phone. For LAN/remote access
the user points the app at the gateway's LAN IP / Tailscale name / a WSS
tunnel (see `09-pairing-security.md`).
- **Push is the only inbound path to a sleeping phone**, and it goes through a
cloud relay (FCM or ntfy), not a direct connection.
## Why the *messaging gateway* is the connection point (not `tui_gateway`)
hermes has two "gateways": the **messaging gateway** (`hermes gateway`, which
serves Telegram/Discord/… and cron) and the **`tui_gateway`** JSON-RPC backend
(used by the TUI and the existing Electron desktop app). We deliberately use the
**messaging gateway** because:
1. **Cron delivery is native.** Cron jobs resolve `deliver=android:<chat>[:<thread>]`
through the platform registry and call our adapter's `send()`. No bridging.
2. **`send_message` tool routing** works out of the box (plugin
`parse_target_ref_fn`).
3. **Slash commands** are dispatched by the gateway's command pipeline — the app
just sends `/cmd args` as a message.
4. **Coexistence.** The same agent is reachable via Telegram *and* the app at
once; sessions/channels are shared.
The `tui_gateway` WS protocol is *not* reused; we define a clean, purpose-built
protocol (`04-wire-protocol.md`) that borrows familiar names (`message.*`,
`tool.*`, `reasoning`) but is owned by our plugin.
## Key architectural decisions + rationale
| Decision | Rationale |
|---|---|
| **Community-style platform plugin** (`register(ctx)` → `ctx.register_platform`) | Zero hermes-core changes; survives hermes updates; follows `ADDING_A_PLATFORM.md` "Plugin Path". |
| **Single WS transport** for chat, streaming, tools, media, pairing, FCM-token | One connection, one auth, one dependency (`websockets`, already core). Media as chunked binary frames avoids adding an HTTP server. |
| **`websockets` + `httpx` only** | Both are hermes *core* deps → the plugin adds **zero** new Python dependencies (respects hermes supply-chain pinning policy). |
| **Structured tool events, app-side verbosity** | Per the requirement: the gateway sends full tool data; the *app* decides everything/truncated/nothing. |
| **Reasoning split in the adapter** | The gateway prepends reasoning to the final text when `show_reasoning` is on; the adapter splits the stable prefix into a `reasoning` field so the app renders a clean collapsible block. |
| **Channels/threads = `chat_id`/`thread_id`** | The gateway's `SessionSource` already models this; cron delivery already targets `platform:chat_id:thread_id`. We map app concepts onto existing primitives. |
| **SQLite outbox + sync cursor** | Offline delivery + reconnect catch-up without re-reading full history. |
| **Compose Multiplatform** | Desktop is "the Android app, tweaked" → share protocol/state/UI; only platform services + layout differ. |
## Data flow (one turn)
1. App sends `message.send {text}` (or `/cmd`).
2. Plugin builds a `MessageEvent` (+ `media_urls` if attachments) →
`AndroidAdapter.handle_message(event)`.
3. Gateway resolves the session (`chat_id`/`thread_id`), runs the agent.
4. Agent streams: `stream_delta_callback` → `GatewayStreamConsumer` →
`adapter.send()` (first) / `adapter.edit_message()` (updates) →
`message.start` / `message.update` frames.
5. Tool calls: `tool_progress_callback` → progress queue → `adapter.send()` →
`tool.*` frames (structured).
6. Intermediate beats: `interim_assistant_callback` → consumer → `commentary` frames.
7. Final answer: consumer finalizes → `adapter.send()` → `message` frame
(reasoning split into its own field).
8. If the app is disconnected at any point: frame is dropped to the **outbox**
and a **push** is fired; on reconnect the app `sync`s the delta.
> Implementation note: the main gateway uses the **legacy callback path**
> (not the ACP-only event-native `render_message_event` path). We therefore map
> the legacy `send`/`edit_message`/progress calls to our frames. The exact
> tool-progress vs commentary classification is verified empirically in M2 by
> running the real gateway with a test WS client (see `13-testing.md`).
+130
View File
@@ -0,0 +1,130 @@
# 02 — Monorepo Structure
One repository, three artifacts. `hermes-agent/` is **not** part of the repo
(git-ignored reference).
## Top-level tree
```
iris_x_hermes/
├── .gitignore # MUST exclude hermes-agent/ (see below)
├── README.md # Repo root readme (short; points to docs/)
├── docs/ # ← THIS reference library
│
├── hermes-agent/ # ⚠️ READ-ONLY REFERENCE — NEVER PUSHED (git-ignored)
│
├── gateway-plugin/ # ① Python plugin → installed to ~/.hermes/plugins/android
│ ├── plugin.yaml # manifest (kind: platform, env vars, home channel)
│ ├── __init__.py
│ ├── adapter.py # AndroidAdapter(BasePlatformAdapter) + register(ctx)
│ ├── ws_server.py # websockets server, connection registry, framing
│ ├── protocol.py # frame schemas (source of truth, mirrored in Kotlin)
│ ├── media.py # inbound cache + outbound chunked streaming
│ ├── outbox.py # SQLite offline outbox + sync cursor
│ ├── push.py # PushBackend: FcmBackend + NtfyBackend
│ ├── pairing.py # token gen/verify, device registry
│ ├── search.py # FTS5 session search bridge
│ └── tests/ # pytest (run via hermes scripts/run_tests.sh)
│
├── app/ # ② + ③ Compose Multiplatform project (Kotlin)
│ ├── settings.gradle.kts
│ ├── build.gradle.kts
│ ├── gradle.properties
│ ├── gradle/ gradlew gradlew.bat
│ ├── shared/ # KMP module — the bulk of the code
│ │ ├── build.gradle.kts
│ │ └── src/
│ │ ├── commonMain/kotlin/iris/… # protocol, WS client, repo, state, Compose UI
│ │ ├── androidMain/kotlin/… # FCM, ExoPlayer, SAF picker, notifications
│ │ └── desktopMain/kotlin/… # tray, file dialog, player, window
│ ├── androidApp/ # thin Android shell (Application, MainActivity)
│ │ ├── build.gradle.kts
│ │ └── src/main/… # AndroidManifest, res, Firebase options
│ └── desktopApp/ # thin Desktop shell (main(), window)
│ ├── build.gradle.kts
│ └── src/main/kotlin/…
│
└── (no other top-level code)
```
## Module responsibilities
### `gateway-plugin/` (Python)
- **`plugin.yaml`** — manifest: `name: android-platform`, `kind: platform`,
`requires_env` / `optional_env` (surfaced in `hermes config`/setup).
- **`adapter.py`** — `AndroidAdapter(BasePlatformAdapter)` + `register(ctx)`.
The heart of the plugin. See `03-gateway-plugin.md`.
- **`ws_server.py`** — `websockets` server, per-device connection registry,
frame encode/decode, heartbeat, broadcast routing to all connected devices.
- **`protocol.py`** — dataclasses/constants for every frame (single source of
truth; `docs/protocol/frames.schema.json` is generated/mirrored from it).
- **`media.py`** — inbound chunked upload → `cache_*_from_bytes`; outbound
`media.offer`/`media.pull` chunked streaming.
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
- **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and
`NtfyBackend` (reuses hermes ntfy publish). Selected by `ANDROID_PUSH_BACKEND`.
- **`pairing.py`** — token generation/verification (constant-time), device
registry (SQLite), QR payload.
- **`search.py`** — FTS5 query bridge over the hermes session store.
### `app/shared` (Kotlin KMP)
- **`commonMain`** — protocol models (kotlinx-serialization), `GatewayClient`
(OkHttp WS), repositories (Room), ViewModels (StateFlow), and the Compose UI
(design system, screens). ~80% of app code.
- **`androidMain`** — FCM service, ExoPlayer, SAF media picker, system
notifications, `MediaPlayer` actual.
- **`desktopMain`** — tray + OS notifications, desktop player, file dialog,
window management, `MediaPlayer` actual.
### `app/androidApp` / `app/desktopApp`
Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
(Desktop). They compose the `shared` UI and inject platform services.
## Build systems
- **Python plugin:** no build step (pure Python, stdlib + hermes core deps).
Installed by copying/symlinking into `~/.hermes/plugins/android`. Tested with
hermes's `scripts/run_tests.sh`.
- **Kotlin/CMP:** Gradle (Kotlin DSL) with the Compose Multiplatform plugin.
`./gradlew :androidApp:installDebug`, `./gradlew :desktopApp:run`,
`./gradlew :shared:testDebugUnitTest`.
## `.gitignore` (root) — critical
```gitignore
# hermes-agent is a read-only research reference — NEVER commit/push it
/hermes-agent/
# Python
__pycache__/
*.pyc
.venv/
venv/
# Kotlin / Gradle
.gradle/
build/
local.properties
*.iml
.idea/
# Android / Firebase
app/androidApp/src/main/res/values/secrets.xml
google-services.json
*.jks
keystore.jks
# OS / misc
.DS_Store
*.log
```
> The `/hermes-agent/` line is non-negotiable. Add a pre-commit guard (or CI
> check) that fails if any path under `hermes-agent/` is staged.
## Install layout (runtime)
- **Plugin:** `~/.hermes/plugins/android/` ← copy of `gateway-plugin/`
(or a symlink for dev). Discovered by hermes's `PluginManager`.
- **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`.
- **App (desktop, dev):** `./gradlew :desktopApp:run`.
+263
View File
@@ -0,0 +1,263 @@
# 03 — Gateway Plugin (Python)
The plugin is a **community-style hermes platform plugin** named `android`.
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: android-platform
label: Android
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: ANDROID_TOKEN
description: "Shared pairing token the app presents on connect"
prompt: "Android pairing token"
password: true
optional_env:
- name: ANDROID_WS_HOST
description: "WS bind host (default 127.0.0.1; use 0.0.0.0 for LAN)"
prompt: "WS host"
password: false
- name: ANDROID_WS_PORT
description: "WS port (default 8790)"
prompt: "WS port"
password: false
- name: ANDROID_HOME_CHANNEL
description: "Default chat id for cron/notification delivery (default android:default)"
prompt: "Home channel"
password: false
- name: ANDROID_ALLOWED_USERS
description: "Comma-separated allowed device_ids (empty = token-only auth)"
prompt: "Allowed device ids"
password: false
- name: ANDROID_ALLOW_ALL_USERS
description: "Allow any paired device (dev only)"
prompt: "Allow all devices? (true/false)"
password: false
- name: ANDROID_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy"
prompt: "Push backend"
password: false
- name: ANDROID_FCM_SERVICE_ACCOUNT
description: "Path to Firebase service-account JSON (FCM HTTP v1)"
prompt: "FCM service account path"
password: true
- name: ANDROID_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 ANDROID_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: ANDROID_WS_CERT
description: "TLS cert path for WSS (optional)"
prompt: "WSS cert"
password: false
- name: ANDROID_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS key"
password: false
```
Behavioral (non-secret) settings live in `config.yaml` under
`gateway.platforms.android.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="android",
label="Android",
adapter_factory=lambda cfg: AndroidAdapter(cfg),
check_fn=check_requirements, # passive: websockets importable + token set
validate_config=validate_config, # host/port/token present
is_connected=is_connected,
required_env=["ANDROID_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="ANDROID_HOME_CHANNEL",
standalone_sender_fn=_standalone_send, # best-effort out-of-proc cron (stretch)
parse_target_ref_fn=_parse_target_ref, # "android:<chat>[:<thread>]"
allowed_users_env="ANDROID_ALLOWED_USERS",
allow_all_env="ANDROID_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
`ANDROID_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": "android:default", "name": "Default"}` so `hermes gateway status`
and cron home-channel resolution work without instantiating the adapter.
- **`_parse_target_ref(ref)`** — if `ref` starts with `android:`, return
`(chat_id, thread_id)` parsed from `android:<chat>[:<thread>]`; else `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 `AndroidAdapter(BasePlatformAdapter)`
Constructor: `super().__init__(config=config, platform=Platform("android"))`.
Reads `config.extra` (env overrides win). Initializes: WS server (not started
until `connect()`), connection registry, outbox (SQLite under
`get_hermes_home()/"android"`), push backend, pairing store, channel directory.
### Lifecycle
- **`connect(*, is_reconnect=False) -> bool`**
- Acquire scoped lock (`gateway.status.acquire_scoped_lock("android", 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 WebSocket server (`ws_server.py`)
- Library: **`websockets`** (core dep, v15). `websockets.serve(handler, host,
port, ssl=ctx)`.
- **Handler** per connection:
1. Await first frame; must be `hello {token, device_id, device_name, caps,
fcm_token?}`. Verify token (constant-time) + allowlist. On failure: send
`error {code:"auth"}` and close.
2. On success: register in connection registry
(`device_id → {ws, caps, fcm_token}`), send
`hello.ack {server_caps, sync_cursor, channels[]}`.
3. Loop: decode frames, dispatch to adapter inbound handlers.
4. On close: deregister; if no devices remain, ensure pending outbox
frames have push fired.
- **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.
- **Heartbeat:** WS ping/pong + app-level `ping`/`pong`; dead peers reaped.
- **Backpressure:** per-connection send queue with a bounded buffer; drop
`message.update` (coalesce to latest) under pressure, never drop
`message`/`tool.end`/`notification`.
## 3.5 State & storage (all under `get_hermes_home()/"android"`)
> 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`):** `ANDROID_TOKEN`, `ANDROID_FCM_SERVICE_ACCOUNT`,
`ANDROID_FCM_SERVER_KEY`, `ANDROID_WS_CERT/KEY`, `NTFY_TOPIC` (if secret).
- **Behavioral (`config.yaml` → `gateway.platforms.android.extra`):** `host`,
`port`, `home_channel`, `allowed_users`, `push_backend`, `outbox_retention_hours`,
`max_upload_bytes`, `tls`.
- 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
- WS server bind failure → `_set_fatal_error("bind_failed", …, retryable=True)`.
- All outbound sends are best-effort; a dead socket latches and the frame falls
to the outbox.
- `disconnect()` cancels the server task and closes sockets cleanly.
- Token/PII redaction in all logs (hermes PII policy).
+327
View File
@@ -0,0 +1,327 @@
# 04 — Wire Protocol
JSON frames over a single WebSocket. One connection per device. Text frames are
JSON; media travels as **binary frames** (chunked) referenced by a header frame.
## Envelope
Every frame:
```json
{
"v": 1,
"id": 42, // optional; present on requests + their responses
"type": "message", // frame type (below)
"chat_id": "android:default", // optional; scope for chat-scoped frames
"thread_id": "t_123", // optional
"payload": { } // type-specific object
}
```
- `v` — protocol version (currently `1`). Server rejects unknown major versions.
- `id` — request id (client-chosen). Responses/acks echo it. Events have no `id`.
- `chat_id` / `thread_id` — top-level for convenience; may also be in `payload`.
- Unknown `type`s are ignored (forward-compat); unknown `payload` fields ignored.
**Binary media frames** are not JSON. A media transfer is: one JSON header frame
(`media.upload.start` / `media.pull` ack) followed by raw binary frames, then a
JSON `media.upload.end` / final ack. See `07-media.md`.
## Server → App (events / responses)
### `hello.ack`
Pairing succeeded.
```json
{"type":"hello.ack","payload":{
"server_caps":{"streaming":true,"reasoning":true,"tools":true,"media":true,
"search":true,"push":"fcm","pickers":true},
"sync_cursor":1042,
"channels":[{"chat_id":"android:default","name":"Default","kind":"default","is_default":true}]
}}
```
### `message`
A final / standalone message.
```json
{"type":"message","chat_id":"android:default","thread_id":null,
"payload":{
"message_id":"m_9001","role":"assistant",
"text":"Here is the answer…",
"reasoning":"The user asked… so I will…", // optional; render ABOVE text
"media":[{"media_id":"md_5","kind":"video","mime":"video/mp4","size":123456,
"filename":"clip.mp4"}], // optional
"reply_to":"m_8999", // optional
"model":"qwen3-27b","tokens":11,"ts":1724000000000
}}
```
`role` ∈ `user | assistant | system | cron`. `reasoning` present only when the
agent produced reasoning and `show_reasoning` is on.
### `message.start` / `message.update` / `message.stop`
Streaming a bubble. `update` carries the **full** current text (app replaces).
```json
{"type":"message.start","chat_id":"…","payload":{"message_id":"m_9002","role":"assistant"}}
{"type":"message.update","chat_id":"…","payload":{"message_id":"m_9002","text":"partial…"}}
{"type":"message.stop","chat_id":"…","payload":{"message_id":"m_9002","final_text":"full…",
"reasoning":"…","model":"…","tokens":11}}
```
### `commentary`
Intermediate assistant beat (between tool iterations).
```json
{"type":"commentary","chat_id":"…","payload":{"message_id":"m_9003","text":"Let me inspect the repo first."}}
```
### `tool.start` / `tool.progress` / `tool.end`
**Structured** tool events. The app decides how much to show (everything /
truncated / nothing).
```json
{"type":"tool.start","chat_id":"…","payload":{
"index":3,"name":"terminal","preview":"pytest -q","args":{"command":"pytest -q"}}}
{"type":"tool.progress","chat_id":"…","payload":{"index":3,"name":"terminal","note":"running…"}}
{"type":"tool.end","chat_id":"…","payload":{"index":3,"name":"terminal","ok":true,"duration":12.4,
"output_preview":"12 passed"}}
```
`args` may be large; the app truncates per its setting. `output_preview` is a
short tail (full output is not streamed — it lives in agent history).
### `typing` / `typing.stop`
```json
{"type":"typing","chat_id":"…","payload":{"on":true}}
```
### `notification`
In-app banner (foreground) and/or push mirror (background).
```json
{"type":"notification","chat_id":"…","payload":{
"kind":"channel_renamed","title":"ARIA","body":"Renamed topic to …","ts":1724000000000}}
```
`kind` ∈ `channel_renamed | channel_created | cron | approval | clarify | generic`.
### `picker.model` / `picker.choice` / `picker.clarify` / `picker.approval` / `picker.confirm`
Interactive prompts. App renders a native picker; answers via `picker.select`.
```json
{"type":"picker.model","chat_id":"…","payload":{
"picker_id":"pm_1","current_model":"qwen3-27b","current_provider":"local",
"providers":[{"id":"local","label":"Local","models":[{"id":"qwen3-27b","label":"Qwen3 27B"}]}]}}
{"type":"picker.choice","chat_id":"…","payload":{
"picker_id":"pc_1","title":"Reasoning effort","choices":[
{"value":"low","label":"Low"},{"value":"high","label":"High","is_current":true}]}}
```
### `channel.list` / `channel.created` / `channel.renamed` / `channel.deleted`
Channel directory updates. **Broadcast to all connected devices** (no explicit
subscribe; the server pushes to every open WS).
```json
{"type":"channel.created","payload":{"chat_id":"android:chan_7","name":"Cron Reports",
"kind":"channel","parent_chat_id":null}}
```
### `history`
Response to a `history` request. Returns a page of messages for a chat/thread.
```json
{"type":"history","id":20,"chat_id":"android:default","thread_id":null,
"payload":{
"messages":[
{"message_id":"m_8990","role":"user","text":"Hi","ts":1723990000000},
{"message_id":"m_8991","role":"assistant","text":"Hello!","reasoning":"…",
"model":"qwen3-27b","tokens":8,"ts":1723990001000}
],
"has_more":true,
"oldest_message_id":"m_8990"
}}
```
`messages` are ordered oldest → newest. Paginate with `before_message_id` in the
request. The app uses this to **populate the initial view** when a channel is
opened (complements `sync`, which only replays undelivered outbox frames).
### `commands.catalog`
Response to a `commands.catalog` request. Full slash-command list.
```json
{"type":"commands.catalog","id":21,"payload":{
"commands":[
{"name":"/new","description":"Start a new session","args_hint":"","category":"session"},
{"name":"/model","description":"Switch model","args_hint":"<provider/model>","category":"config"},
{"name":"/reasoning","description":"Toggle reasoning effort","args_hint":"[low|medium|high]","category":"config"},
{"name":"/status","description":"Show session status","args_hint":"","category":"info"},
{"name":"/cron","description":"Manage cron jobs","args_hint":"<list|add|rm>","category":"automation"},
{"name":"/tools","description":"List available tools","args_hint":"","category":"info"}
]}}
```
### `commands.complete`
Response to a `commands.complete` request. Autocomplete matches for a typed prefix.
```json
{"type":"commands.complete","id":22,"payload":{
"prefix":"/mod",
"matches":[
{"name":"/model","description":"Switch model","args_hint":"<provider/model>"}
]}}
```
### `agent.busy` / `agent.idle`
Agent lifecycle for a chat/thread. App shows a "thinking…" indicator on `busy`.
```json
{"type":"agent.busy","chat_id":"android:default","thread_id":null,
"payload":{"reason":"processing"}}
{"type":"agent.idle","chat_id":"android:default","thread_id":null,"payload":{}}
```
`reason` ∈ `processing | tool | waiting_input | cron`.
### `search.results`
```json
{"type":"search.results","id":7,"payload":{
"query":"deploy","scope":"all","hits":[
{"message_id":"m_123","chat_id":"android:chan_7","thread_id":null,
"role":"assistant","snippet":"…deploy the service…","ts":1723900000000}]}}
```
### `media.offer`
Agent-sent media is available; app pulls bytes.
```json
{"type":"media.offer","chat_id":"…","payload":{
"media_id":"md_5","kind":"video","mime":"video/mp4","size":123456,"filename":"clip.mp4"}}
```
### `status`
Gateway lifecycle / session info.
```json
{"type":"status","payload":{"state":"online","session":{"chat_id":"…","model":"…","tokens":11}}}
```
`state` ∈ `online | restarting | degraded`.
### `error`
```json
{"type":"error","id":7,"payload":{"code":"not_found","message":"chat_id unknown"}}
```
`code` ∈ `auth | not_found | rate_limited | media_too_large | unsupported | internal`.
### `pong`
Keepalive reply to `ping`.
## App → Server (requests / actions)
### `hello`
First frame; auth + caps.
```json
{"type":"hello","payload":{
"token":"<ANDROID_TOKEN>","device_id":"dev_a1b2","device_name":"MIX 2S",
"caps":{"min_protocol":1,"media":true,"push":"fcm"},
"fcm_token":"<FCM token>","ntfy_topic":"<topic, if ntfy>"}}
```
### `message.send`
Send text (or a `/slash-command`).
```json
{"type":"message.send","id":10,"chat_id":"android:default","thread_id":null,
"payload":{"text":"/model qwen3-27b","reply_to":"m_9001","media_refs":["mu_1"]}}
```
`media_refs` reference completed `media.upload`s to attach.
### `media.upload.start` / (binary) / `media.upload.end`
See `07-media.md`.
```json
{"type":"media.upload.start","id":11,"payload":{
"media_ref":"mu_1","kind":"image","mime":"image/jpeg","size":204800,"filename":"a.jpg"}}
// … binary frames …
{"type":"media.upload.end","id":11,"payload":{"media_ref":"mu_1","sha256":"…"}}
```
### `media.pull`
Request agent-sent media bytes.
```json
{"type":"media.pull","id":12,"payload":{"media_id":"md_5"}}
// server replies: binary frames, then {"type":"media.pull.end","id":12,"payload":{"ok":true}}
```
### `picker.select`
Answer an interactive picker.
```json
{"type":"picker.select","id":13,"payload":{"picker_id":"pm_1","value":"local/qwen3-27b"}}
```
### `channel.create` / `channel.rename` / `channel.set_default` / `channel.delete`
```json
{"type":"channel.create","id":14,"payload":{"name":"Cron Reports","kind":"channel"}}
{"type":"channel.rename","id":15,"chat_id":"android:chan_7","payload":{"name":"Reports"}}
{"type":"channel.set_default","id":16,"chat_id":"android:chan_7","payload":{}}
```
### `search`
```json
{"type":"search","id":17,"payload":{"query":"deploy","scope":"all"}}
{"type":"search","id":18,"payload":{"query":"deploy","scope":"chat","chat_id":"android:chan_7","thread_id":null}}
```
`scope` ∈ `all | chat`.
### `read.receipt`
App → server: "user has viewed this message." Server stores the read state and
broadcasts to other devices (for multi-device ✓✓ sync). The app uses it to
mark messages as read locally (✓✓ on user bubbles).
```json
{"type":"read.receipt","payload":{"chat_id":"android:default","message_id":"m_9001"}}
```
### `history`
Load a page of messages for a chat/thread (initial open, scroll-up pagination).
```json
{"type":"history","id":20,"chat_id":"android:default","thread_id":null,
"payload":{"before_message_id":"m_8990","limit":50}}
```
`before_message_id` — return messages older than this (omit for newest page).
`limit` — max messages (default 50, max 200).
### `commands.catalog`
Fetch the full slash-command catalog (for the `Menü` bottom sheet).
```json
{"type":"commands.catalog","id":21,"payload":{}}
```
### `commands.complete`
Autocomplete for a typed `/prefix`.
```json
{"type":"commands.complete","id":22,"payload":{"prefix":"/mod"}}
```
### `agent.stop`
Stop the current agent turn (abort generation / tool execution).
```json
{"type":"agent.stop","id":23,"chat_id":"android:default","thread_id":null,"payload":{}}
```
### `agent.steer`
Inject a steering message mid-turn (redirects the agent without a new turn).
```json
{"type":"agent.steer","id":24,"chat_id":"android:default","thread_id":null,
"payload":{"text":"Actually, focus on the error case."}}
```
### `sync`
Reconnect catch-up. Replays **undelivered outbox frames** (frames sent while
this device was offline). Does NOT load full history — use `history` for that.
```json
{"type":"sync","id":19,"payload":{"cursor":1042}}
// server replays outbox frames with cursor > 1042, then {"type":"sync.done","id":19,"payload":{"cursor":1099}}
```
### `fcm.register`
Update push token.
```json
{"type":"fcm.register","payload":{"fcm_token":"<new>","ntfy_topic":"<topic>"}}
```
### `ping`
Keepalive. `{"type":"ping","payload":{"ts":1724000000000}}` → `pong`.
## Ordering & reliability
- Frames are ordered per connection (TCP/WS). Streaming `message.update` frames
for a `message_id` are monotonic; the app may coalesce to the latest.
- Terminal frames (`message`, `message.stop`, `tool.end`, `notification`,
`picker.*`, `channel.*`, `agent.busy`, `agent.idle`, `history`,
`commands.catalog`, `commands.complete`) are **never dropped** under
backpressure; only intermediate `message.update`/`tool.progress` are coalesced.
- Anything not delivered live goes to the **outbox** and is replayed by `sync`.
- **Broadcast:** channel directory events (`channel.*`) and read-receipts are
pushed to **all** connected devices for that gateway (no subscribe step).
- Requests get exactly one response or `error` (matched by `id`).
+143
View File
@@ -0,0 +1,143 @@
# 05 — Streaming, Reasoning, Tools, Intermediate Messages
This doc covers the four "agent transparency" features and exactly how each is
produced by the gateway and rendered by the app.
> The main hermes gateway delivers via the **legacy callback path** (not the
> ACP-only event-native `render_message_event` path). We map the legacy
> `send`/`edit_message`/progress calls to our WS frames.
## 5.1 Text streaming
**Gateway side.** The agent's `stream_delta_callback` feeds a
`GatewayStreamConsumer` (`gateway/stream_consumer.py:156`). The consumer
accumulates text and, at intervals / thresholds, calls:
- `adapter.send(chat_id, text)` — first time a bubble is created.
- `adapter.edit_message(chat_id, message_id, text)` — subsequent updates
(each carries the **full** accumulated text).
**Adapter → frames.**
- First `send()` of a turn segment → `message.start {message_id, role}`.
- Each `edit_message()` → `message.update {message_id, text}` (full text).
- Segment/turn finalization → `message.stop {message_id, final_text, reasoning?,
model?, tokens?}` (or a final `message` frame when not streaming).
**App side.** Maintain a live bubble per `message_id`. On `message.update`,
replace the bubble text (cheap: it's a full snapshot). On `message.stop`,
finalize (attach reasoning/model/tokens footer, stop the cursor). Auto-scroll
while the user is at the bottom.
**Streaming on/off.** Controlled by hermes `display.platforms.android.streaming`
(default follows global). When off, the app just gets one final `message` frame.
## 5.2 Reasoning (shown *before* the message)
**Requirement:** reasoning appears as a block **above** the answer (like the
reference screenshot's "Reasoning:" panel with a copy button).
**Gateway side.** hermes prepends reasoning to the final response when
`show_reasoning` is enabled (`gateway/run.py:20089`). The format is stable and
chosen by `reasoning_style` (`gateway/display_config.py:37`):
- `code` (default): `💭 **Reasoning:**\n```\n<reasoning>\n```\n\n<response>`
- `blockquote`: `> 💭 **Reasoning:**\n> …\n\n<response>`
- `subtext`: `-# 💭 Reasoning\n-# …\n\n<response>` (Discord-style)
**Plugin config.** Set for the `android` platform:
```yaml
display:
platforms:
android:
show_reasoning: true
reasoning_style: code # we split on the code-fence form
```
**Adapter split.** In `send()`, detect the `code`-style prefix and split:
```
prefix = "💭 **Reasoning:**\n```\n"
# find the closing "\n```\n\n" after the prefix
reasoning = text[len(prefix):close_idx]
body = text[close_idx + len("\n```\n\n"):]
```
Emit `message {reasoning: <reasoning>, text: <body>, …}`. If no prefix is found
(reasoning off / no reasoning), emit `message {text: …}` with no `reasoning`.
> Robustness: the split is a best-effort parse of a *stable, gateway-owned*
> format. If the format ever changes, the fallback is "no reasoning field, full
> text" — the app still shows the answer. Verified in M2 against the live
> gateway.
**App side.** `ReasoningBlock` composable: collapsible, header "💭 Reasoning",
monospace body, a **copy** button (matches reference). Rendered **above** the
message body. Collapsed by default if long; expanded tap.
## 5.3 Tool output (app controls verbosity)
**Requirement:** hermes supports several tool-display modes; the **gateway sends
full structured data**, and the **app** chooses how much to show
(everything / truncated / nothing).
**Gateway side.** Tool activity flows through `tool_progress_callback` → the
gateway progress queue → `send_progress_messages` (`gateway/run.py:4603`) →
`adapter.send()`. The adapter receives tool lines during a turn.
**Adapter → frames.** The adapter classifies tool activity (via turn-state +
line format) and emits **structured** frames — not pre-formatted strings:
- `tool.start {index, name, preview, args}` — a tool call began.
- `tool.progress {index, name, note}` — in-progress update (optional).
- `tool.end {index, name, ok, duration, output_preview}` — completed.
`index` is a monotonic per-turn counter so the app correlates start→end.
`args` is the full argument dict (the app truncates). `output_preview` is a
short tail; full tool output is **not** streamed (it lives in agent history and
is reachable via search).
**App side — the verbosity setting** (Settings → "Tool detail"):
- **Everything** — show tool name, full args (collapsible), and output preview.
- **Truncated** (default) — show `emoji name: "short preview"` one-liner,
collapsible to expand.
- **Nothing** — suppress `tool.*` frames entirely (clean chat).
Rendering: a `ToolCard` per tool, grouped under the message it belongs to, with
a spinner while `tool.end` hasn't arrived, a ✓/✗ on completion, and duration.
> Classification detail: the exact way to distinguish a tool-progress `send()`
> from a regular `send()`/commentary is confirmed empirically in M2 by running
> the real gateway with a test WS client and observing the calls. The adapter
> keeps a per-chat turn state machine (turn active, current streaming id, last
> tool index) to make the classification deterministic.
## 5.4 Intermediate messages (commentary)
**Requirement:** show the agent's interim beats (e.g. "I'll inspect the repo
first.") as distinct messages.
**Gateway side.** `interim_assistant_callback` → consumer `on_commentary`
(`gateway/stream_consumer.py:518`) → delivered as its own message.
**Adapter → frames.** `commentary {message_id, text}`.
**App side.** Render as a **dimmed / smaller** bubble, visually distinct from
final answers (e.g. reduced opacity, no model footer). It reads as a "beat" in
the conversation, not a full reply.
## 5.5 Typing indicator
`send_typing` → `typing {on:true}`; the app shows an animated indicator in the
chat header / above the composer until `typing.stop` or the first
`message.start`.
## 5.6 Frame sequence for a typical turn
```
app → message.send {text:"summarize the repo"}
srv → typing {on:true}
srv → message.start {message_id:m1}
srv → message.update {m1, "Let me look…"} (streaming)
srv → tool.start {index:1, name:terminal, args:{command:"ls -la"}}
srv → tool.end {index:1, name:terminal, ok:true, duration:0.4}
srv → commentary {m2, "Found 12 files."}
srv → message.start {message_id:m3}
srv → message.update {m3, "The repo has…"}
srv → message.stop {m3, final_text:"…", reasoning:"…", model:"…", tokens:42}
srv → typing {on:false}
```
+107
View File
@@ -0,0 +1,107 @@
# 06 — Channels, Threads, Cron Delivery, Search
## 6.1 Concept model
The hermes gateway already models conversations as `SessionSource` with
`chat_id` + `thread_id` + `chat_topic` (`gateway/platforms/base.py:7047`
`build_source`). We map app concepts onto these existing primitives — **no new
gateway identity concepts**.
| App concept | hermes primitive | Example |
|---|---|---|
| Default chat | home channel `chat_id` | `android:default` |
| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=android:default, thread_id=t_12` |
| A user-created channel | a new `chat_id` | `android:chan_7` |
| A thread inside a channel | `thread_id` under that `chat_id` | `chat_id=android:chan_7, thread_id=t_31` |
- **`chat_id`** = the conversation lane (a channel or the default chat).
- **`thread_id`** = an optional sub-lane within a `chat_id` (topic-like).
- The **channel directory** (plugin SQLite, `channels.db`) stores
`{chat_id, name, kind: default|channel|thread, parent_chat_id, is_default,
created}` and is the source of truth for the app's channel list.
## 6.2 Default chat
- On first connect, the plugin ensures a **default channel** exists:
`chat_id = ANDROID_HOME_CHANNEL` (default `android:default`), `kind=default`,
`is_default=true`, name "Default".
- It is also the **cron home channel** (`cron_deliver_env_var=
ANDROID_HOME_CHANNEL`), so `deliver=android` (bare) routes here.
- The app opens the default chat on launch.
## 6.3 Threads (toggle for overview)
- **Requirement:** a default chat where the user can **activate threads** for a
better overview, or not.
- **Thread toggle** (per default chat, in the chat header menu):
- **Threads OFF** — flat conversation; all messages use `thread_id=null`.
- **Threads ON** — the app groups the conversation into topic-like lanes.
Each new "topic" mints a `thread_id` (via `channel.create {kind:thread,
parent_chat_id:android:default}` or an implicit thread). The UI shows a
topic switcher (like Telegram topics) above the message list.
- Threads are **app-organized** but **gateway-real**: each `thread_id` is a
distinct hermes session lane, so context is isolated per thread and cron can
target a specific thread.
- The gateway's `create_handoff_thread` is used where hermes wants to open a
named thread (e.g. continuable cron).
## 6.4 User-created channels (for cron delegation)
- **Requirement:** the user creates new channels so **cron job outputs can be
delegated to them** instead of the default chat.
- **`channel.create {name, kind:"channel"}`** → plugin mints
`chat_id = android:chan_<n>`, stores in directory, broadcasts
`channel.created` to all devices. The new channel appears in the channel list.
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
directory (rename broadcasts `channel.renamed`; delete is soft — marks
archived, keeps history for search).
- **Cron targeting** (the key payoff): because the plugin registers
`parse_target_ref_fn` and `cron_deliver_env_var`, cron jobs and the
`send_message` tool can target any channel/thread:
- `deliver="android"` → home (default) channel.
- `deliver="android:android:chan_7"` → that channel.
- `deliver="android:android:chan_7:t_31"` → that channel's thread.
- In-chat: the agent's `cronjob` tool can be told "deliver to the *Cron
Reports* channel"; the gateway resolves the name via the channel directory.
- **In-app affordance:** each channel's menu has "Set as cron target" / shows a
badge when a cron job points at it, and the channel name is offered in the
`/cron` creation flow.
## 6.5 Cron delivery mechanics (how it works under the hood)
- Cron resolves delivery targets in `cron/scheduler.py:2148`
(`_resolve_single_delivery_target`). For `platform:chat_id[:thread_id]` it
calls `tools.send_message_tool.resolve_send_target`, which uses our
`parse_target_ref_fn` to parse `android:<chat>[:<thread>]`.
- Delivery then calls the **live adapter's `send(chat_id, text, …)`** (gateway
running) → our WS `message` frame (or outbox+push if the app is offline).
- Cron deliveries are framed with a `[Cron delivery: <name>]` header by hermes;
the app can style cron messages distinctly (e.g. a small "⏰ <job name>"
chip) using the `role:"cron"` / header.
- **Mirror option:** hermes `cron.mirror_delivery` (default off) can also mirror
a cron delivery into the origin session; we leave it off to keep channels
clean.
## 6.6 Search
- **Requirement:** search with settings "search everywhere" / "search in this
chat/channel".
- **Backend:** hermes's session store is **SQLite + FTS5**
(`hermes_state.py`, `hermes_state_search.py`). The plugin's `search.py` opens
the session DB read-only and runs FTS5 queries.
- **`search` frame** → `search.results`:
- `scope:"all"` — search **everywhere** (all channels/threads/sessions).
- `scope:"chat"` — restrict to the given `chat_id` (and optional `thread_id`).
- **Result hit:** `{message_id, chat_id, thread_id, role, snippet, ts}`. The app
renders a results list; tapping a hit navigates to that channel/thread and
scrolls to + highlights the message.
- **Query syntax:** plain text (FTS5). Optional `role:` / `channel:` qualifiers
are a nice-to-have; v1 is plain-text + scope.
- **Privacy:** search is local to the user's own hermes home; no data leaves the
machine.
## 6.7 Channel list frame
`hello.ack` and `channel.*` frames carry the directory. App keeps a local copy
(Room) and reconciles on `channel.*` events (merge, don't clobber — see
`10-android-app.md` state rules).
+96
View File
@@ -0,0 +1,96 @@
# 07 — Media (upload, download, playback)
Media travels **over the WebSocket** as chunked binary frames (decision: no
separate HTTP server; keeps the plugin to `websockets` only). Both directions
use the same chunking.
## 7.1 Kinds & MIME
`kind` ∈ `image | audio | video | document | voice`.
- `image` — `image/*` (jpg/png/webp/gif/heic).
- `audio` — `audio/*` (mp3/m4a/ogg/…) — music.
- `video` — `video/*` (mp4/webm/mov).
- `document` — anything else (pdf, docx, zip, txt, …).
- `voice` — short voice note (`audio/ogg; codecs=opus` typical).
The app sniffs `kind` from the picked file's MIME; the plugin re-sniffs on
receipt (don't trust the client) using hermes helpers
(`gateway/platforms/base.py` `_sniff_audio_ext`, `_looks_like_image`).
## 7.2 Inbound (app → agent) — `media.upload`
**Flow:**
1. App picks a file (SAF) → reads size + MIME.
2. App sends `media.upload.start {media_ref, kind, mime, size, filename}`.
3. App streams the file as **binary WS frames** (e.g. 256 KiB chunks).
4. App sends `media.upload.end {media_ref, sha256}`.
5. Plugin verifies size ≤ `max_upload_bytes` and sha256, writes to the media
cache via hermes `cache_*_from_bytes`:
- image → `cache_image_from_bytes`
- audio/voice → `cache_audio_from_bytes`
- video → `cache_video_from_bytes`
- document → `cache_document_from_bytes`
→ returns a local path.
6. The path is attached to the next `message.send` via `media_refs`, becoming
`MessageEvent.media_urls` + `media_types`
(`gateway/platforms/base.py:2337`). The agent's vision/audio tools can then
read the file.
**Limits:** `get_inbound_media_max_bytes()` / `validate_inbound_media_size`
(`base.py:758/779`) enforce the cap; over-limit → `error {code:"media_too_large"}`.
**Backpressure:** large uploads use the WS flow control; the plugin reads
binary frames into a temp file (not memory) to bound RAM.
## 7.3 Outbound (agent → app) — `media.offer` / `media.pull`
**Flow:**
1. Agent produces/references media (e.g. generates an image, or replies with a
`MEDIA:` tag / image URL). hermes base `extract_media` / `extract_images`
(`base.py:4439/4884`) pull these out and call the adapter's
`send_image / send_video / send_document / send_voice / send_image_file /
send_multiple_images`.
2. Adapter stages the file in the media cache, mints a `media_id`, and emits
`media.offer {media_id, kind, mime, size, filename}` (inside/with the
`message` frame's `media[]`).
3. App sends `media.pull {media_id}`.
4. Plugin streams the file as **binary WS frames**; ends with
`media.pull.end {ok:true}`.
5. App writes to its cache dir and hands the path to the player/viewer.
**Security:** `validate_media_delivery_path` (`base.py:1684`) + the media
delivery root/recency/denied-path checks (`base.py:1312-1480`) ensure the plugin
only serves files hermes is allowed to deliver (no arbitrary file read).
## 7.4 Live playback (AI-sent music/video)
**Requirement:** play AI-sent music and video **in the app**.
- **Audio (music/voice):** `ExoPlayer` (Media3). Inline player in the bubble
(play/pause, seek, duration); a persistent **mini-player** for music that
survives navigation. Voice notes play inline with a waveform.
- **Video:** `ExoPlayer` inline player (play/pause, seek, fullscreen, PiP on
Android). Streams from the local cache file after `media.pull`.
- **Documents/images:** image viewer (zoom) / open-with for documents (Android
`Intent.ACTION_VIEW` with a `FileProvider` URI; Desktop opens with the system
handler).
- **Desktop:** ExoPlayer is Android-only → the `MediaPlayer` expect/actual uses
a desktop backend (see `11-desktop-app.md`): a `libmpv`/`mpv`-backed surface
or a WebView fallback for video, and a desktop audio player for music.
## 7.5 Chunking parameters
- Chunk size: **256 KiB** (tunable).
- Binary frames carry raw bytes only; framing/metadata is in the JSON header +
end frames.
- Reassembly is ordered (WS preserves order); a gap/corruption → abort +
`error {code:"internal"}` + retry the whole transfer.
- `sha256` in `media.upload.end` / a size check on pull verify integrity.
## 7.6 App-side storage
- Cache dir: app-specific external cache (`getExternalCacheDir()/media`).
- LRU eviction by size (configurable, default 500 MB) so old media doesn't fill
the device.
- A `MediaRepository` tracks `{media_id, local_path, kind, size, ts}` in Room so
bubbles can re-render players after process death.
+105
View File
@@ -0,0 +1,105 @@
# 08 — Push Notifications, Outbox & Sync
The gateway can't reach a sleeping phone directly. Push goes through a cloud
relay. **Decision: FCM primary, ntfy fallback** (`ANDROID_PUSH_BACKEND`).
## 8.1 When push fires
- A frame targets a `chat_id` whose device is **disconnected** (WS closed) →
drop to **outbox** + fire **push**.
- Also fire push for high-priority foreground events the user should see even if
the app is backgrounded (approvals, clarifies, cron completions) — the app
decides whether to also show an in-app banner.
- If the device is **connected**, no push (the live frame is enough).
## 8.2 `PushBackend` interface (`push.py`)
```python
class PushBackend(Protocol):
name: str
async def send(self, *, device_id: str, chat_id: str,
title: str, body: str,
data: dict) -> bool: ...
def configured(self) -> bool: ...
```
Selected at adapter init by `ANDROID_PUSH_BACKEND` (`fcm` default, `ntfy`).
### 8.2.1 `FcmBackend` (primary)
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
account** (`ANDROID_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
OAuth2 access token (cached, refreshed before expiry).
- Fallback: legacy **server key** (`ANDROID_FCM_SERVER_KEY`) if no service
account (simpler, but legacy).
- Target = the device's **FCM token** (registered via `hello` /
`fcm.register`, stored in `devices.db`).
- Payload:
- `notification {title, body}` → system notification (tap → open app).
- `data {chat_id, thread_id, kind, message_id, cursor}` → app uses to `sync`.
- **Data-only option:** for background catch-up, send a data message (silent) so
the app's `FirebaseMessagingService` wakes a foreground service and syncs
without a visible notification (used for non-urgent updates).
- Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few
devices).
### 8.2.2 `NtfyBackend` (fallback, self-host friendly)
- Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter).
- Publish to `NTFY_TOPIC` on `NTFY_SERVER_URL` (default `https://ntfy.sh`) via
`httpx` POST, with an `X-Title` / `X-Message` / `X-Tag` / `X-Priority` and a
JSON `data` attachment (`{chat_id, thread_id, kind, cursor}`).
- The app subscribes to the topic (via an ntfy client lib or a lightweight
listener) to receive pushes without Firebase.
- Best for users who self-host ntfy and want zero Firebase.
## 8.3 Outbox (`outbox.py`)
- SQLite `outbox.db`: rows `{cursor (monotonic), chat_id, thread_id, frame_json,
ts, delivered}`.
- **Write** on every outbound frame that has no live subscriber (or always, then
mark delivered on live send — simpler + crash-safe).
- **Retention:** `outbox_retention_hours` (default 72h); prune on write.
- **Cursor:** global monotonic int; `hello.ack` returns the current cursor;
`sync {cursor}` replays rows with `cursor > given`.
- Bounded: if the outbox grows past a cap (e.g. 5k rows), oldest are pruned and
a `notification {kind:"generic", body:"Older messages pruned"}` is sent.
## 8.4 Sync (reconnect catch-up)
1. App reconnects → `hello` → `hello.ack {sync_cursor}`.
2. App sends `sync {cursor: <last seen>}`.
3. Server replays outbox frames (in cursor order) → app applies them (messages,
tools, channels, media offers).
4. Server sends `sync.done {cursor: <new>}`.
5. App updates its local cursor (Room) — idempotent (dedupe by `message_id`).
This makes the app **eventually consistent** across disconnects, restarts, and
gateway restarts.
## 8.5 App-side push handling (Android)
- **`FirebaseMessagingService.onMessageReceived`** (foreground): show in-app
banner + optionally sync.
- **`onNewToken`** → `fcm.register` the new token.
- **Background data message** → start a **foreground service** (low-priority,
notification channel "Sync") → open WS → `sync` → stop.
- **Notification channels** (Android 8+): one per chat so cron channels can have
their own style/priority (e.g. "Cron Reports" = high, "Default" = default).
Tapping a notification deep-links to the chat/thread.
- **ntfy mode:** a foreground service maintains the ntfy subscription; incoming
events trigger the same sync path.
## 8.6 In-app banners (foreground)
`notification` frames (e.g. `channel_renamed`, `approval`, `clarify`, `cron`)
render as a transient banner above the composer (like the reference screenshot's
"ARIA hat Thema … umbenannt"). Dismissible; high-priority ones (approval/clarify)
persist until acted on.
## 8.7 Security
- FCM tokens are per-device, revocable; stored only in `devices.db`.
- Push payloads carry **no secrets** and no full message bodies larger than a
short preview (privacy on lock screen). Full content is fetched via `sync`
over the authenticated WS.
- ntfy: use a **private topic + auth token** for any real trust boundary (hermes
ntfy adapter guidance).
+95
View File
@@ -0,0 +1,95 @@
# 09 — Pairing, Auth & Security
## 9.1 Threat model
- **Trusted domain:** a personal agent on the user's own machine; one owner, a
few of their own devices (phone + desktop).
- **Primary risks:** (a) an unauthorized device connecting to the WS and
reading/driving the agent; (b) eavesdropping on the WS in transit; (c) token
leakage in logs; (d) arbitrary file read via media pull.
- **Not addressing (v1):** multi-tenant isolation, adversarial multi-user abuse,
E2E encryption.
## 9.2 Pairing flow
1. **Generate a token.** `hermes gateway setup` (our `interactive_setup`) either
uses an existing `ANDROID_TOKEN` or generates a fresh high-entropy token
(e.g. 32 bytes → 64 hex chars) and stores it in `.env`.
2. **Present to the app.** Two options:
- **QR code:** the setup prints a QR encoding
`iris://pair?host=<lan-ip>&port=8790&token=<token>` (or a WSS URL). The
phone scans it with the app's camera (or a system scanner) → pre-fills
settings.
- **Manual:** user types the server URL + token in the app's Connect screen.
3. **App connects.** First WS frame is `hello {token, device_id, device_name,
caps, fcm_token?}`.
4. **Server verifies.** Constant-time compare of `token` vs `ANDROID_TOKEN`
(`hmac.compare_digest`). Optionally check `device_id` against
`ANDROID_ALLOWED_USERS` (if set) or `ANDROID_ALLOW_ALL_USERS`.
5. **On success:** register the device in `devices.db`, send `hello.ack`.
**On failure:** send `error {code:"auth"}` and close.
`device_id` is a stable, app-generated UUID (persisted in the app's
secure storage). It identifies the device for routing + push, **not** as a
security principal (the token is).
## 9.3 Auth model
- **Token = the security principal.** Any connection presenting the valid
`ANDROID_TOKEN` is authorized (it's the user's own token).
- **Allowlist (optional):** `ANDROID_ALLOWED_USERS` (comma-separated
`device_id`s) restricts which *devices* may connect even with the token —
useful if the token is shared. `ANDROID_ALLOW_ALL_USERS=true` disables the
allowlist (dev only).
- **Per-device tokens (stretch):** mint a unique token per device at pairing
(revocable) instead of one shared token. v1 uses the shared token + optional
device allowlist.
- **Re-pairing:** rotating `ANDROID_TOKEN` invalidates all devices; they must
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR.
## 9.4 Transport security
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
network.
- **WSS (recommended for remote):** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY`
(self-signed or CA-signed). The app pins/accepts the cert (self-signed → user
confirms fingerprint on first pair, like a SSH host key).
- **Remote reachability options** (documented, user's choice):
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
app connects over the private mesh. No public exposure.
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
at the edge, forward WS to `127.0.0.1:8790`.
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
in secure storage.
## 9.5 Secret & PII handling
- **Tokens/keys never logged.** Redact `ANDROID_TOKEN`, FCM tokens/keys, ntfy
tokens in all log output (hermes PII policy; `agent/redact.py` patterns).
- **`device_id`** is a random UUID (not PII). `device_name` is user-chosen.
- **Media pull** is gated by hermes `validate_media_delivery_path` + delivery
root/recency/denied-path checks (`gateway/platforms/base.py:1684`) — the
plugin can only serve files hermes is allowed to deliver (no arbitrary file
read).
- **Search** is local-only (user's own hermes home); no data leaves the machine.
## 9.6 Profile safety
- All plugin state lives under `get_hermes_home()/"android"` (profile-aware).
- Secrets are read 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 (fail-closed under `gateway.multiplex_profiles`).
- The WS bind uses a scoped lock (`gateway.status.acquire_scoped_lock`) so two
profiles can't bind the same port/identity.
## 9.7 Hardening checklist
- [ ] Constant-time token compare.
- [ ] Bounded per-connection send buffer + rate limit on inbound frames.
- [ ] Reject oversized frames / uploads (`max_upload_bytes`).
- [ ] Verify media sha256 + re-sniff MIME (don't trust client).
- [ ] Redact all secrets in logs.
- [ ] WSS + cert pinning for remote.
- [ ] Outbox retention cap + prune.
- [ ] Fail-closed secret reads under multiplexing.
+197
View File
@@ -0,0 +1,197 @@
# 10 — Android App (Kotlin + Jetpack Compose)
Native client. Lives in the Compose Multiplatform project at `app/`; the bulk of
the code is in `app/shared` (commonMain) so the Desktop app reuses it.
## 10.1 Tech stack
| Concern | Choice |
|---|---|
| Language | Kotlin |
| UI | Jetpack Compose (Material 3), Compose Navigation |
| Async | Kotlinx Coroutines + Flow |
| WS client | OkHttp (`WebSocketListener`) |
| JSON | kotlinx-serialization |
| Local DB | **SQLDelight** (KMP; channels, messages cache, media index, sync cursor, settings) |
| Media playback | Media3 **ExoPlayer** (audio + video) |
| Push | Firebase Messaging (FCM) [primary] / ntfy listener [fallback] |
| DI | Hilt |
| Images | Coil |
| minSdk / target | **26** / 34 (test device: MIX 2S, API 29) |
## 10.2 Module layout
```
app/shared/src/
├── commonMain/kotlin/iris/
│ ├── protocol/ # frame data classes (mirror of gateway protocol.py)
│ ├── net/ # GatewayClient (OkHttp WS), reconnect, heartbeat, dispatch
│ ├── data/ # ChannelRepository, MessageRepository, MediaRepository,
│ │ # SearchRepository, SettingsRepository (SQLDelight + WS)
│ ├── state/ # ViewModels: ChatListVM, ChatVM, ComposerVM, SettingsVM
│ ├── ui/
│ │ ├── theme/ # Material 3 theme (dark default), type, color
│ │ ├── components/ # MessageBubble, ReasoningBlock, ToolCard, MediaPlayer,
│ │ │ # ChannelRow, Composer, PickerSheet, SearchBar, Banner
│ │ └── screens/ # ConnectScreen, ChatListScreen, ChatScreen,
│ │ # SearchScreen, SettingsScreen, ChannelMenu
│ └── util/ # time formatting, markdown, id gen
├── androidMain/kotlin/iris/
│ ├── platform/ # MediaPlayer actual (ExoPlayer), MediaPicker (SAF),
│ │ # Notifications, SecureStore (EncryptedSharedPreferences)
│ ├── fcm/ # FirebaseMessagingService, foreground sync service
│ └── IrisApp.kt # Application (Hilt), notification channels
└── androidApp/ # MainActivity, AndroidManifest, res, google-services
```
## 10.3 `GatewayClient` (net)
- OkHttp `WebSocket` to `ws(s)://host:port/ws`.
- **Reconnect:** exponential backoff + jitter; on reconnect send `hello` then
`sync {cursor}` (replays undelivered outbox only).
- **Initial channel open:** on first open of a chat/thread, send `history`
(newest page) to populate the view. `sync` does NOT load history.
- **Heartbeat:** send `ping` every 20s; reap on missed `pong` (3×) → reconnect.
- **Request/response:** map of `id → CompletableDeferred`; events → a
`SharedFlow<Frame>` consumed by repositories.
- **Backpressure:** collect frames on a bounded channel; coalesce
`message.update` per `message_id` (keep latest) to avoid flooding the UI.
- **Thread:** all frame emission on a single dispatcher; UI observes via Flow.
## 10.4 State rules (from hermes desktop AGENTS.md — apply here)
- **Server is authoritative** for channels/messages/cursor; the app's Room copy
is a **cache**. Reconcile (merge, don't clobber) on `channel.*`/`sync`.
- **Optimistic then honest:** send a message → show it immediately (pending),
roll back visibly on `error`, authoritative `sync` gets the last word.
- **Guard against the past:** generation counters / request tokens so a stale
response never overwrites newer intent.
- **Isolate the foreground:** only the visible chat publishes into the shared
view; background chats update their own cache quietly.
- **Coalesce noise, flush signal:** batch cosmetic updates; let terminal
transitions (turn done, needs input, failed) reach the user immediately.
## 10.5 Feature implementation (your checklist)
### Input box, auto-grow (max height)
- Compose `BasicTextField` inside a `Box` with
`Modifier.heightIn(min = 1.line, max = 160.dp)`. Grows with lines, caps at
160dp, then scrolls internally.
- Bottom bar (matches reference): `Menü` button (left), emoji button, the
auto-grow field, attach (paperclip), mic/send (right). Send on Enter
(configurable: Enter=send vs Enter=newline).
### Menu button → all slash commands
- `Menü` opens a **bottom sheet** listing the command catalog. The catalog is
served by the gateway via `commands.catalog` request → response with
`{name, description, args_hint, category}` per command, so it always matches
hermes (`/new`, `/model`, `/reasoning`, `/status`, `/cron`, `/tools`, …).
- Typing `/` in the field shows **autocomplete** via `commands.complete`
request (gateway matches the typed prefix). Selecting inserts `/cmd `.
- Selecting a command sends `message.send {text:"/cmd args"}`.
- **Interactive commands** (`/model`, `/reasoning`, `/fast`, approvals,
clarifies) render as **native pickers** from `picker.*` frames (a dialog /
sheet with the options; answer via `picker.select`).
### Tool output (app-controlled verbosity)
- `ToolCard` renders `tool.start/progress/end` frames.
- **Settings → "Tool detail": Everything / Truncated / Nothing.**
- Everything: name + full args (collapsible) + output preview.
- Truncated (default): `emoji name: "short preview"` one-liner, expandable.
- Nothing: suppress tool frames.
- Spinner while running; ✓/✗ + duration on `tool.end`.
### Reasoning before message
- `ReasoningBlock` (collapsible, "💭 Reasoning" header, monospace body, **copy**
button) rendered **above** the message body from the `reasoning` field.
Matches the reference screenshot.
### Intermediate messages
- `commentary` frames → dimmed/smaller bubble, distinct from final answers.
### Agent busy / stop / steer
- `agent.busy` → show "thinking…" indicator in chat header (animated dots).
- `agent.idle` → clear indicator.
- **Stop button** (appears in header while busy): sends `agent.stop`.
- **Steer:** while busy, the composer accepts input; sending it calls
`agent.steer` (injects mid-turn) rather than queuing a new `message.send`.
### Threading + channels
- **Channel list** (drawer on single-pane; left rail on two-pane) = default chat
+ user channels (avatar, name, last-message preview, unread badge, active
highlight bar) — matches the reference left sidebar.
- **Thread toggle** in the default chat header: "Threads on/off". On → topic
switcher above the message list (each topic = a `thread_id`).
- **New channel** (FAB / channel-list menu) → `channel.create` → appears in list;
menu offers "Set as cron target".
### Search
- Search bar (chat header or top) with a **scope toggle**: "Search everywhere" /
"Search in this chat/channel". → `search` frame → results list → tap jumps to
the message (navigate + highlight).
### Attach media
- Paperclip → system pickers (Photos / Files / Audio / Video / Docs) via SAF.
- Selected files show as **preview chips** in the composer (thumbnail + name +
remove). On send: `media.upload` (chunked) for each, then `message.send` with
`media_refs`.
### Voice input (mic button)
- Mic button (right of composer, toggles to send when text is present).
- Tap → request `RECORD_AUDIO` permission → start recording (MediaRecorder,
OGG/Opus, 44.1 kHz mono).
- While recording: timer + waveform; tap again to stop.
- On stop: file becomes a **preview chip** (audio, kind=`voice`) in the
composer, same as attached media. Send → `media.upload` (chunked) →
`message.send` with `media_refs`.
- Hermes receives it as an audio attachment; the agent's STT (if configured)
transcribes it. No client-side STT.
### Push notifications
- FCM service (see `08-push.md`): foreground banner + background foreground
service → `sync`. Notification channel per chat. Tap → deep-link to chat.
- ntfy fallback: foreground service maintains the subscription.
### Live playback
- AI-sent audio/video → `media.pull` → cache file → **ExoPlayer** inline player
(audio: mini-player; video: inline + fullscreen + PiP). Documents/images →
viewer / open-with.
## 10.6 Layout (Telegram-style, per reference image)
**Two layout modes** (decision: user-toggleable, **single-pane default**):
- **Single-pane (default on phones):** chat full-screen; channel list in a
swipeable drawer (hamburger / edge swipe).
- **Two-pane (Telegram-style, like the reference):** persistent left channel
rail + chat. Auto-enabled on tablets / large screens; toggleable in Settings.
**Chat screen anatomy (matches reference):**
- **Header:** back (single-pane), avatar, name + "Bot" subtitle, edit + overflow
(⋮) menu (thread toggle, channel menu, set cron target, clear).
- **Message list:** date separators ("7. August"); user bubbles **right**
(accent color, ✓✓ read receipts); agent bubbles **left** (surface color) with
optional ReasoningBlock + ToolCards + media + model/token footer
("Qwen3-… · 11% · ~") + timestamp.
- **In-app banner** above the composer (e.g. "renamed topic").
- **Composer:** `Menü` / emoji / auto-grow input / attach / mic-send.
**Theme:** dark by default (reference is dark); Material 3; optional dynamic
color. Accent = user's chosen brand color (default indigo, like the reference).
## 10.7 SQLDelight schema (cache)
- `channels(chat_id PK, name, kind, parent_chat_id, is_default, last_preview,
last_ts, unread)`.
- `messages(id PK, chat_id, thread_id, role, text, reasoning, model, tokens,
ts, status[pending|sent|read], media_json)`.
- `media(media_id PK, local_path, kind, mime, size, ts)`.
- `meta(key PK, value)` — sync cursor, settings, device_id, server url, pinned
cert fingerprint.
## 10.8 Onboarding / Connect screen
- First launch → **Connect**: server URL + token (or scan QR). "Test connection"
does a real `hello` (not just a TCP probe — per hermes desktop guidance, the
auth leg must be exercised). On success → save (secure storage) → main.
- States: connecting / connected / reconnecting / degraded / auth-failed — each
with honest copy and a way out.
+75
View File
@@ -0,0 +1,75 @@
# 11 — Desktop App (Kotlin + Compose Multiplatform)
The desktop app is **the Android app, tweaked for a big screen**. It reuses the
entire `app/shared` module (protocol, network, repositories, state, most UI) and
only adds desktop platform services + a wider default layout.
## 11.1 What's shared vs desktop-specific
| Layer | Shared (commonMain) | Desktop-specific (desktopMain) |
|---|---|---|
| Protocol / WS client | ✅ | — |
| Repositories / state | ✅ | — |
| Most Compose UI | ✅ | layout tweaks, keyboard shortcuts |
| Push | — | **tray icon + OS notifications** (no FCM) |
| Media playback | `MediaPlayer` interface | **desktop player** actual |
| File picker | `MediaPicker` interface | **desktop file dialog** actual |
| Window | — | resizable window, always-on-top, global hotkey |
| Secure store | `SecureStore` interface | keyring / encrypted file actual |
## 11.2 `desktopMain` platform services
- **Push → tray + OS notifications.** Desktop is assumed reachable (persistent
WS), so no FCM. A **system tray icon** shows connection state + unread count;
OS notifications (Java Desktop / `SystemTray` + a cross-platform notifier)
fire for background chats / approvals / cron. Clicking focuses the window and
deep-links to the chat.
- **Media playback → `MediaPlayer` actual.** ExoPlayer is Android-only. Desktop
uses a `libmpv`/`mpv`-backed Compose surface for video (and audio), with a
WebView-based fallback if `mpv` isn't available. Same `MediaPlayer` interface
the Android `ExoPlayer` actual implements, so UI code is identical.
- **File picker → `MediaPicker` actual.** A native file dialog (Compose
Desktop `FileChooser` / AWT `JFileChooser`) returning local file paths, then
the same `media.upload` chunked path.
- **Window.** Resizable, remembers size/position, optional always-on-top, a
global hotkey to focus/show. Minimize-to-tray option.
- **Secure store → `SecureStore` actual.** OS keychain (macOS Keychain, Linux
Secret Service / encrypted file, Windows Credential Manager) or an encrypted
file for the token + pinned cert.
## 11.3 Layout (big-screen tweaks)
- **Two-pane is the default** (persistent left channel rail + chat), since there
is width. Single-pane still available via the same toggle.
- **Wider chat column** with comfortable max line length; optional **third
pane** (inspector: session info, model, tokens, tool detail, channel settings).
- **Larger type scale** and spacing for desktop; hover states; mouse + keyboard
first.
- **Keyboard shortcuts:**
- `/` focus the composer and open the command menu.
- `Ctrl/Cmd+K` command palette (all slash commands + actions).
- `Ctrl/Cmd+N` new channel; `Ctrl/Cmd+T` new thread.
- `Ctrl/Cmd+F` search (with the all/this-chat scope toggle).
- `↑/↓` navigate channel list; `Enter` open.
- `Esc` close sheet/dialog (one cancel gesture does exactly one thing).
- **Multi-window (stretch):** open a channel in a separate window / pop-out.
## 11.4 Distribution
- **Compose Desktop** → native binaries via **jpackage** (or a plain app image):
Linux (.deb/.AppImage), macOS (.dmg), Windows (.msi/.exe).
- Bundles the JRE. Auto-update is a stretch goal (v1: manual download).
- The desktop app connects to the **same** gateway WS server as the phone (the
user's home server / Tailscale). It does **not** spawn its own backend (unlike
hermes's existing Electron desktop, which spawns `hermes serve`) — our
desktop is a pure client of the messaging gateway, matching the Android app.
## 11.5 Parity checklist (same functionality as Android)
- [ ] Streaming, reasoning block, tool cards (3-level verbosity), commentary.
- [ ] Channels + threads + new channel + cron target.
- [ ] Search (all / this-chat).
- [ ] Media attach + live playback (via desktop player).
- [ ] Slash command menu + autocomplete + interactive pickers.
- [ ] Push (tray + OS notifications) + outbox/sync.
- [ ] Pairing/Connect screen (URL + token, WSS cert pin).
+138
View File
@@ -0,0 +1,138 @@
# 12 — Toolchain Setup
First-time setup on a machine (verified baseline: CachyOS/Arch, `pacman`,
`uv` present, ADB present, no JDK/SDK/Gradle).
## 12.1 JDK 17
```bash
pacman -S jdk17-openjdk
java -version # expect 17.x
```
(Compose Multiplatform + current AGP are happy on JDK 17. Use 17 to match the
Android toolchain; 21 also works but 17 is the safe floor.)
## 12.2 Android SDK
```bash
# cmdline-tools
mkdir -p ~/android-sdk/cmdline-tools
cd ~/android-sdk/cmdline-tools
curl -O https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
unzip commandlinetools-linux-*.zip && mv cmdline-tools latest
rm commandlinetools-linux-*.zip
export ANDROID_HOME=$HOME/android-sdk
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools
sdkmanager --licenses
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"
```
Persist `ANDROID_HOME`/`PATH` in `~/.bashrc`. ADB is already installed system-wide;
`platform-tools` from the SDK is fine too (whichever is first on `PATH`).
Create `app/local.properties`:
```
sdk.dir=/home/<you>/android-sdk
```
## 12.3 Gradle
No system install — use the project wrapper:
```bash
cd app
./gradlew tasks # first run downloads the wrapper distribution
```
(The wrapper version is pinned in `app/gradle/wrapper/gradle-wrapper.properties`.)
## 12.4 hermes environment (for the plugin + running the gateway)
```bash
cd hermes-agent
uv sync # creates .venv with all core deps (websockets, httpx, …)
source .venv/bin/activate
hermes --version # sanity
```
- Run the gateway with the plugin:
```bash
# install the plugin (dev: symlink)
mkdir -p ~/.hermes/plugins
ln -s "$PWD/../gateway-plugin" ~/.hermes/plugins/android
hermes gateway status # should list "android"
hermes gateway # run
```
- Tests use hermes's hermetic runner (never bare `pytest`):
```bash
scripts/run_tests.sh tests/gateway/test_android.py
```
## 12.5 Firebase (FCM) — primary push
1. Create a Firebase project (console.firebase.google.com).
2. Add an **Android app** (package = `androidApp` applicationId, e.g.
`dev.iris.app`). Download `google-services.json` → `app/androidApp/`.
3. Create a **service account** (Project settings → Service accounts → Generate
new private key) → download the JSON. Store its path in
`ANDROID_FCM_SERVICE_ACCOUNT` (in `~/.hermes/.env`).
4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and
registers it via `hello` / `fcm.register`.
> Skip Firebase → set `ANDROID_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` /
> `NTFY_SERVER_URL` (self-host ntfy or use ntfy.sh). See `08-push.md`.
## 12.6 Environment variables (summary)
**Secrets (`~/.hermes/.env`):**
```
ANDROID_TOKEN=<64-hex>
ANDROID_PUSH_BACKEND=fcm # or ntfy
ANDROID_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
# ANDROID_FCM_SERVER_KEY=<legacy key> # fallback if no service account
# NTFY_TOPIC=iris-push # when ntfy
# NTFY_SERVER_URL=https://ntfy.sh
# ANDROID_WS_CERT=/path/cert.pem # WSS
# ANDROID_WS_KEY=/path/key.pem
```
**Behavioral (`~/.hermes/config.yaml`):**
```yaml
gateway:
platforms:
android:
enabled: true
extra:
host: 127.0.0.1 # 0.0.0.0 for LAN
port: 8790
home_channel: android:default
push_backend: fcm
outbox_retention_hours: 72
max_upload_bytes: 104857600 # 100 MB
display:
platforms:
android:
show_reasoning: true
reasoning_style: code
streaming: true
tool_progress: all # gateway sends full data; app controls display
```
## 12.7 Verify the stack (smoke test)
```bash
# 1. gateway up with plugin
hermes gateway status | grep -i android
# 2. a raw WS client can pair + echo
python - <<'PY'
import asyncio, json, websockets
async def main():
async with websockets.connect("ws://127.0.0.1:8790/ws") as ws:
await ws.send(json.dumps({"v":1,"type":"hello","payload":{
"token":"<ANDROID_TOKEN>","device_id":"test","device_name":"probe",
"caps":{"min_protocol":1}}}))
print("recv:", await ws.recv())
asyncio.run(main())
PY
```
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
wrong.
+131
View File
@@ -0,0 +1,131 @@
# 13 — Testing & Debugging (incl. ADB on-device)
Three layers: **Python plugin tests**, **Kotlin unit/UI tests**, and **on-device
E2E via ADB**. Plus a **WS test-client harness** to drive the real gateway
without the app (critical for verifying frame shapes early).
## 13.1 Python plugin tests
- Location: `gateway-plugin/tests/` (and, for hermes-integration tests, mirror
into the hermes `tests/gateway/test_android.py` pattern when running under
hermes's suite).
- **Run with hermes's hermetic runner** (never bare `pytest`):
```bash
cd hermes-agent
scripts/run_tests.sh tests/gateway/test_android.py
scripts/run_tests.sh # full suite (CI parity)
```
- Coverage to write (behavioral, not change-detector — per hermes test policy):
- `register(ctx)` produces a valid `PlatformEntry` (name, cron env var,
parse_target_ref).
- `check_requirements` / `validate_config` / `is_connected` truth table.
- `_parse_target_ref`: `android:<chat>`, `android:<chat>:<thread>`, non-android
→ None.
- **Reasoning split:** given a `show_reasoning`-style final text, `send()`
emits `message {reasoning, text}` correctly; no-prefix → no reasoning field.
- **Outbox:** frame with no subscriber → written with a cursor; `sync` replays
the right range; retention prunes.
- **Pairing:** valid token → `hello.ack`; wrong token → `error auth` + close.
Constant-time compare used.
- **Media:** upload start→binary→end reassembles + sha256 verified; over-limit
→ `media_too_large`; pull serves only allowed paths.
- **Push backend selection:** fcm vs ntfy chosen by config; `configured()`
reflects missing creds.
- **Channel directory:** create/rename/set_default; cron target resolution.
- **No `~/.hermes` writes in tests** — use the `_isolate_hermes_home` fixture
pattern (temp `HERMES_HOME`). Profile tests also mock `Path.home()`.
## 13.2 WS test-client harness (do this FIRST, in M1/M2)
A small Python script (`gateway-plugin/tests/ws_probe.py`) that connects to the
**real running gateway** and drives a turn, printing every frame. This is how we
**empirically confirm** the exact frame shapes (especially tool-progress vs
commentary classification and the reasoning prefix) before/while building the
Kotlin client.
```bash
hermes gateway & # with the android plugin
python gateway-plugin/tests/ws_probe.py --token <ANDROID_TOKEN> \
--send "list the files and summarize"
# prints: hello.ack, typing, message.start, message.update…, tool.start, tool.end,
# commentary, message.stop {reasoning,…}, …
```
Use it to lock `04-wire-protocol.md` against reality and to debug the adapter
without waiting for the app.
## 13.3 Kotlin unit / UI tests
- **Protocol codec:** round-trip every frame type (serialize → deserialize →
equal); unknown `type`/fields ignored (forward-compat).
- **Repositories:** merge-don't-clobber on `channel.*`; optimistic send + rollback
on `error`; sync dedupe by `message_id`; cursor monotonic.
- **`GatewayClient`:** reconnect/backoff; `message.update` coalescing (latest
wins); request/response correlation by `id`.
- **ViewModels:** state transitions (connecting→connected→reconnecting); tool
verbosity filtering (everything/truncated/nothing); reasoning present/absent.
- **Compose UI tests:** ReasoningBlock collapse/expand + copy; ToolCard
spinner→done; composer auto-grow cap; channel list active highlight.
- Run: `./gradlew :shared:testDebugUnitTest` (and `:shared:testDesktopTest`).
## 13.4 On-device E2E via ADB (the MIX 2S, API 29)
Device: `a5ca2a4b` (Xiaomi MIX 2S). Workflow:
```bash
# install + launch
cd app
./gradlew :androidApp:installDebug
adb shell am start -n dev.iris.app/.MainActivity
# logs (filter our tags + WS + FCM + ExoPlayer)
adb logcat -c
adb logcat | grep -Ei "iris|GatewayClient|Firebase|ExoPlayer|MediaCodec"
# screenshots for visual checks
adb exec-out screencap -p > /tmp/shot.png
# clear app data between pairing attempts
adb shell pm clear dev.iris.app
# push a file to the app's cache (for media tests) / pull logs
adb shell run-as dev.iris.app ls files
adb logcat -d > /tmp/logcat.txt
```
**E2E scenarios (script where possible):**
1. **Pair:** connect screen → enter URL+token → `hello.ack` → main. (Verify auth
leg, not just TCP.)
2. **Text round-trip:** send "hello" → streamed reply appears (message.start →
updates → stop).
3. **Reasoning:** ask a reasoning-model question → ReasoningBlock shows above the
answer; copy button works.
4. **Tools:** trigger a tool (e.g. "list files") → ToolCard shows; toggle
verbosity in Settings → rendering changes.
5. **Intermediate:** a multi-step prompt → commentary bubble appears dimmed.
6. **Channels:** create "Cron Reports" → appears in list; set as cron target.
7. **Cron delivery:** create a cron job `deliver=android:android:chan_<n>` → it
fires → lands in that channel (not default).
8. **Search:** "search everywhere" vs "this chat" → correct scoping; tap → jump.
9. **Media (in):** attach a photo + a video → agent receives (vision) → reply.
10. **Media (out):** ask the agent to send an image/video → `media.offer` →
inline player plays it live.
11. **Push:** background the app (`adb shell am start` another app / lock) →
trigger a message → FCM notification appears → tap → syncs + deep-links.
12. **Reconnect/sync:** kill the WS (stop gateway briefly) → restart → app
reconnects → `sync` catches up (no lost/dup messages).
## 13.5 Debugging tips
- **Gateway side:** `~/.hermes/logs/gateway.log` (and `hermes logs --follow`).
Our plugin logs under the `android` adapter name; secrets redacted.
- **WS framing bugs:** use the `ws_probe.py` harness — it isolates the protocol
from the app.
- **Streaming jitter:** the consumer edits at intervals; if updates look chunky,
check `display.platforms.android.streaming` and the consumer's edit interval.
- **Media pull stalls:** check chunk size + backpressure; confirm the file is
within hermes delivery roots (`validate_media_delivery_path`).
- **FCM not arriving:** confirm the token registered (`devices.db`), the service
account can mint a token, and the app's `onNewToken` re-registered after
`pm clear`.
- **Profile leaks:** if tokens look wrong under multiple profiles, verify the
scope-aware secret read (`_get_scoped_secret`) is used.
+138
View File
@@ -0,0 +1,138 @@
# 14 — Milestones (M0–M7)
Phased delivery. Each milestone ends with a **demo** (on-device where noted) and
has explicit **acceptance criteria**. Work top-to-bottom; don't skip M0/M1.
---
## M0 — Toolchain & scaffolding
**Goal:** everything builds; the plugin is discoverable; the repo is safe.
- [ ] Install JDK 17, Android SDK, set `ANDROID_HOME` (`12-toolchain.md`).
- [ ] `cd hermes-agent && uv sync` (hermes venv works).
- [ ] Create monorepo scaffold (`02-monorepo.md`): `gateway-plugin/`, `app/`
(CMP: `shared`, `androidApp`, `desktopApp`), root `.gitignore`
(**excludes `hermes-agent/`**), root `README.md`.
- [ ] `git init` + a pre-commit/CI guard that fails if `hermes-agent/` is staged.
- [ ] CMP project builds empty: `./gradlew :androidApp:assembleDebug`,
`./gradlew :desktopApp:run` (blank window).
- [ ] Plugin skeleton: `plugin.yaml` + `adapter.py` with `register(ctx)` + a
no-op `AndroidAdapter` → `hermes gateway status` lists **android**.
- **Demo:** `hermes gateway status` shows `android`; `./gradlew
:androidApp:installDebug` installs a blank app on the MIX 2S.
- **Accept:** blank app installs + launches on-device; plugin visible in
`hermes gateway status`; `hermes-agent/` is git-ignored (verify with
`git status --ignored`).
## M1 — Gateway core loop (text round-trip)
**Goal:** pair + send a text message + get a (non-streaming) reply.
- [ ] WS server (`ws_server.py`): bind, `hello` auth (constant-time),
`hello.ack`, heartbeat, connection registry.
- [ ] `AndroidAdapter.send()` → `message` frame; inbound `message.send` →
`MessageEvent` → `handle_message`.
- [ ] Pairing store + `ANDROID_TOKEN`; QR payload in `interactive_setup`.
- [ ] App: Connect screen (URL+token, real `hello` test), `GatewayClient`
(connect + reconnect), ChatScreen sends + renders `message`.
- [ ] `ws_probe.py` harness drives a real turn.
- **Demo (on-device):** pair the phone, send "hello", see the agent's reply.
- **Accept:** text round-trip works on-device; wrong token is rejected;
reconnect after gateway restart re-pairs.
## M2 — Streaming + reasoning + tools + commentary
**Goal:** the "agent transparency" features.
- [ ] Map consumer `send`/`edit_message` → `message.start/update/stop`.
- [ ] Reasoning: set `show_reasoning` for android; adapter splits prefix →
`reasoning` field. **Verify format with `ws_probe.py`.**
- [ ] Tool events: classify tool-progress `send()`s → structured
`tool.start/progress/end` (turn-state machine). **Verify with probe.**
- [ ] Commentary → `commentary` frames. Typing → `typing`.
- [ ] App: live bubble (coalesced updates), `ReasoningBlock` (collapse + copy),
`ToolCard` with **Everything/Truncated/Nothing** setting, dimmed
`commentary` bubble.
- **Demo (on-device):** a multi-step prompt streams, shows reasoning above the
answer, tool cards (toggle verbosity), and an intermediate beat.
- **Accept:** all four render correctly; tool verbosity setting changes
rendering; reasoning copy button works; frame shapes match `04-wire-protocol`.
## M3 — Channels/threads + cron + search
**Goal:** organization + cron delegation + search.
- [ ] Channel directory (SQLite): default channel ensured; `channel.create/
rename/set_default/delete` + `channel.*` frames.
- [ ] Threads: toggle in default chat; `thread_id` lanes; `create_handoff_thread`.
- [ ] `parse_target_ref_fn` + `cron_deliver_env_var` → cron
`deliver=android:<chat>[:<thread>]` works.
- [ ] `search.py` FTS5 bridge; `search` frame (all / this-chat) → results.
- [ ] App: channel list (drawer/rail), thread toggle + topic switcher, "new
channel" + "set as cron target", SearchScreen with scope toggle + jump.
- **Demo (on-device):** create "Cron Reports"; create a cron job delivering to
it; it fires into that channel; search finds a message (both scopes).
- **Accept:** cron output lands in the chosen channel (not default); threads
isolate context; search scoping correct; channel list reconciles on events.
## M4 — Media
**Goal:** attach + receive + play media.
- [ ] Inbound: `media.upload` chunked → `cache_*_from_bytes` → `media_urls`;
size limit + sha256 + MIME re-sniff.
- [ ] Outbound: `send_*` → `media.offer`; `media.pull` chunked; delivery-path
security.
- [ ] App: SAF pickers + preview chips + upload; `media.pull` → cache;
**ExoPlayer** inline (audio mini-player, video fullscreen/PiP); image/doc
viewers.
- **Demo (on-device):** attach a photo + video (agent sees them); ask agent to
send an image/video → plays live in-app.
- **Accept:** both directions work; over-limit rejected; playback is live;
only allowed files are servable.
## M5 — Push + offline (FCM + ntfy)
**Goal:** reach the phone when backgrounded; catch up on reconnect.
- [ ] Outbox (SQLite) + sync cursor; `sync`/`sync.done`; retention prune.
- [ ] `push.py`: `FcmBackend` (HTTP v1 + service account, httpx) +
`NtfyBackend`; selected by `ANDROID_PUSH_BACKEND`.
- [ ] Fire push on no-live-subscriber; data payload for silent sync.
- [ ] App: FCM service (foreground banner + background foreground-service sync),
`onNewToken` → `fcm.register`; per-chat notification channels; deep-link.
ntfy listener fallback.
- [ ] In-app `notification` banners (channel_renamed, approval, cron, …).
- **Demo (on-device):** background the app → trigger a message → notification
appears → tap → syncs + opens the chat. Repeat with ntfy backend.
- **Accept:** push arrives when backgrounded (both backends); reconnect syncs
with no loss/dup; banners show for foreground events.
## M6 — Desktop app
**Goal:** the same app on a big screen.
- [ ] `desktopMain`: tray + OS notifications; `MediaPlayer` actual (mpv/WebView);
`MediaPicker` actual (file dialog); `SecureStore` actual; window mgmt.
- [ ] Two-pane default layout; keyboard shortcuts; optional inspector pane.
- [ ] Parity pass vs Android feature checklist (`11-desktop-app.md`).
- [ ] jpackage builds (Linux first; macOS/Windows as available).
- **Demo:** desktop app pairs to the same gateway; full feature parity; tray
notifications; media plays.
- **Accept:** all Android features work on desktop; tray + shortcuts work;
native binary launches.
## M7 — Polish + E2E + docs
**Goal:** ship-quality.
- [ ] Telegram-style layout pass (per reference image): header, bubbles, date
separators, ✓✓, model/token footer, banner, bottom bar.
- [ ] Theming (dark default, accent), onboarding/pairing UX, empty/loading/
reconnecting/degraded states with honest copy.
- [ ] Full E2E suite (`13-testing.md` scenarios 1–12) automated where possible.
- [ ] Docs: `docs/protocol/frames.schema.json` finalized; `docs/setup.md`
(user-facing pairing + FCM/ntfy + remote access); root README.
- [ ] Security hardening checklist (`09-pairing-security.md`) verified.
- **Demo:** end-to-end on phone + desktop simultaneously; cron into a channel;
push; media; search.
- **Accept:** all feature-checklist items pass on-device; E2E green; docs
complete; `hermes-agent/` still never committed.
---
## Sequencing notes
- **M1/M2 depend on the `ws_probe.py` harness** to lock frame shapes early —
build it in M1.
- **M3 (cron) and M5 (push) both touch the outbox** — build the outbox in M3,
extend for push in M5.
- **M6 (desktop) reuses M1–M5 shared code** — do it after the Android features
are stable so the shared module is settled.
- Parallelizable: plugin (Python) and app (Kotlin) can be worked on
concurrently once the protocol (`04-wire-protocol.md`) is agreed; the probe
harness is the integration seam.
+136
View File
@@ -0,0 +1,136 @@
# 15 — hermes-agent Source Reference Map
A cheat-sheet of the **exact hermes-agent files/lines** to read for each
integration point. Paths are relative to `hermes-agent/` (the read-only
reference). This lets a coder jump straight to the right code instead of
re-deriving the architecture.
> ⚠️ Read-only. We **install** our plugin into `~/.hermes/plugins/android`; we
> never edit these files.
## Plugin / platform registration
| What | Where |
|---|---|
| How to add a platform (Plugin Path) | `gateway/platforms/ADDING_A_PLATFORM.md` |
| Canonical plugin-platform example | `plugins/platforms/irc/adapter.py` (esp. `register(ctx)` at :953, `_env_enablement` :677, `_standalone_send` :743, `_get_scoped_secret` :42) |
| ntfy plugin example (push-ish) | `plugins/platforms/ntfy/adapter.py`, `plugin.yaml` |
| `register_platform()` (PluginContext) | `hermes_cli/plugins.py:2774` |
| `PlatformEntry` dataclass (all fields) | `gateway/platform_registry.py:63` |
| `Platform` enum | `gateway/config.py` |
| Plugin discovery (`PluginManager`) | `hermes_cli/plugins.py` |
## Base adapter contract
| What | Where |
|---|---|
| `BasePlatformAdapter` (ABC) | `gateway/platforms/base.py:2890` |
| `MessageEvent` (inbound) | `gateway/platforms/base.py:2300` |
| `MessageType` | `gateway/platforms/base.py:2278` |
| `SendResult` | `gateway/platforms/base.py:2466` |
| `build_source(...)` (SessionSource) | `gateway/platforms/base.py:7047` |
| `handle_message(event)` | `gateway/platforms/base.py:5981` |
| `send()` / `edit_message()` / `delete_message()` | `base.py:3920 / 3976 / 4005` |
| `send_typing` / `stop_typing` | `base.py:4298 / 4307` |
| Media send: `send_image/video/document/voice/animation/image_file/multiple_images` | `base.py:4396 / 4632 / 4659 / 4486 / 4415 / 4339 / 7834(tg)` |
| `extract_images` / `extract_media` / `extract_local_files` | `base.py:4439 / 4884 / 5020` |
| Media cache helpers: `cache_image/audio/video/document_from_bytes` | `base.py:854 / 1005 / 1122 / 2126` |
| Inbound media size: `get_inbound_media_max_bytes` / `validate_inbound_media_size` | `base.py:758 / 779` |
| Media delivery security: `validate_media_delivery_path` + roots/recency/denied | `base.py:1684 / 1312-1480` |
| Interactive: `send_slash_confirm` / `send_clarify` / `send_private_notice` | `base.py:4169 / 4204 / 4278` |
| `create_handoff_thread` | `base.py:3949` |
| Streaming hooks: `supports_draft_streaming` / `send_draft` / `render_message_event` / `format_tool_event` | `base.py:3215 / 3274 / 3316 / 3337` |
| Scoped secret read pattern (profile-safe) | `plugins/platforms/irc/adapter.py:42` |
| Scoped lock (profile-safe bind) | `gateway/status.py` (`acquire_scoped_lock`) |
## Streaming (legacy callback path — what the main gateway uses)
| What | Where |
|---|---|
| `GatewayStreamConsumer` (the sink) | `gateway/stream_consumer.py:156` |
| `on_delta` / `on_commentary` / `on_segment_break` / `finish` | `stream_consumer.py:611 / 518 / 514 / 623` |
| Consumer `run()` loop (edit cadence) | `stream_consumer.py:781` |
| Structured events (ACP-only, NOT main gateway) | `gateway/stream_events.py`, `gateway/stream_dispatch.py:40` |
| Callback wiring (agent → consumer) | `gateway/run.py:5642-5704` (`tool_progress_callback`, `stream_delta_callback`, `interim_assistant_callback`, `status_callback`, `event_callback`) |
| `send_progress_messages` (tool progress → `send`) | `gateway/run.py:4603` |
| Progress metadata | `gateway/run.py:28089` |
## Reasoning display
| What | Where |
|---|---|
| Reasoning prepended to final response | `gateway/run.py:20089-20127` |
| `show_reasoning` / `reasoning_style` defaults + per-platform | `gateway/display_config.py:33-181` |
| `resolve_display_setting()` | `gateway/display_config.py:187` |
| `last_reasoning` in agent result | `gateway/run.py:6471` |
| Think-tag filtering in consumer | `stream_consumer.py:175-185, 627+` |
## Display settings (tool progress, interim, streaming, etc.)
| What | Where |
|---|---|
| All overrideable display keys + defaults | `gateway/display_config.py:33` (`tool_progress`, `show_reasoning`, `reasoning_style`, `tool_preview_length`, `streaming`, `interim_assistant_messages`, `long_running_notifications`, `cleanup_progress`, `live_status`) |
| Per-platform tiers | `gateway/display_config.py:81-181` |
## Slash commands
| What | Where |
|---|---|
| `COMMAND_REGISTRY` / `CommandDef` (all commands) | `hermes_cli/commands.py:144+` |
| `GATEWAY_KNOWN_COMMANDS` / `is_gateway_known_command` / `resolve_command` | `hermes_cli/commands.py` |
| Gateway command dispatch (alias, access, hooks) | `gateway/run.py:16974-17099` |
| `send_model_picker` / `send_choice_picker` (Telegram ref) | `plugins/platforms/telegram/adapter.py:6350 / 6424` |
| Picker invocation from gateway | `gateway/slash_commands.py:1859, 2157, 3622-3657` |
| Button-callback id conventions (`cl:`, `appr:`, `sc:`) | `gateway/platforms/ADDING_A_PLATFORM.md` (Interactive UX) |
## Cron delivery
| What | Where |
|---|---|
| Resolve a delivery target (`platform:chat_id[:thread_id]`) | `cron/scheduler.py:2148` (`_resolve_single_delivery_target`) |
| `_resolve_delivery_targets` / routing tokens (`all`) | `cron/scheduler.py:2296 / 2278` |
| `cron_deliver_env_var` handling (home channel) | `cron/scheduler.py:1903+` |
| `deliver` param normalization | `cron/scheduler.py:2251` |
| `send_message` tool target resolution (`resolve_send_target`, `prepare_send_message_platforms`) | `tools/send_message_tool.py` |
| `parse_target_ref_fn` usage | `tools/send_message_tool.py` (`_parse_target_ref`) |
| Cron mirror delivery (default off) | `cron/scheduler.py:1520` |
| `cronjob` tool schema (`deliver` description) | `tools/cronjob_tools.py` |
## Search (FTS5)
| What | Where |
|---|---|
| Session store (SQLite + FTS5) | `hermes_state.py` |
| Search implementation | `hermes_state_search.py` |
| Schema | `hermes_state_schema.py` |
## Config / env / profiles
| What | Where |
|---|---|
| `get_hermes_home()` / `display_hermes_home()` (profile-safe paths) | `hermes_constants.py` |
| `DEFAULT_CONFIG` / `OPTIONAL_ENV_VARS` | `hermes_cli/config.py` |
| Gateway config load (`load_gateway_config`, `_apply_env_overrides`) | `gateway/config.py` |
| Profile override (`_apply_profile_override`) | `hermes_cli/main.py` |
| Secret scope (multiplex fail-closed) | `agent/secret_scope.py` |
| PII redaction | `agent/redact.py` |
## Dependencies (confirm zero new deps)
| What | Where |
|---|---|
| `websockets==15.0.1` (core) | `pyproject.toml:111` |
| `httpx[socks]==0.28.1` (core) | `pyproject.toml:44` |
| `aiohttp` (messaging extra, NOT core) | `pyproject.toml:185` |
| Dependency pinning policy | `pyproject.toml:19-39`, root `AGENTS.md` |
## Testing
| What | Where |
|---|---|
| Hermetic test runner (use this, not bare pytest) | `scripts/run_tests.sh` |
| `_isolate_hermes_home` fixture | `tests/conftest.py` |
| Example platform tests | `tests/gateway/test_*.py` (e.g. `test_google_chat.py`, `test_line_plugin.py`) |
| Stream-event tests (dispatcher) | `tests/gateway/test_stream_events.py` |
## Existing desktop app (for reference only — we do NOT reuse its backend)
| What | Where |
|---|---|
| Electron desktop app | `apps/desktop/` (its own `AGENTS.md`, `DESIGN.md`) |
| Shared JSON-RPC WS client (tui_gateway protocol) | `apps/shared/src/json-rpc-gateway.ts` |
| tui_gateway WS transport (mobile-client-ready) | `tui_gateway/ws.py` |
| tui_gateway method catalog | `tui_gateway/server.py` |
> Note: the existing desktop app talks to the **`tui_gateway`** backend
> (`hermes serve`), a *different* process from the messaging gateway. Our apps
> talk to the **messaging gateway** via our own plugin + protocol. We borrow
> naming conventions only.
+75
View File
@@ -0,0 +1,75 @@
# 16 — Decisions & Open Questions
## Locked decisions (from planning, 2026-08-19)
| # | Decision | Choice | Rationale |
|---|---|---|---|
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Android app, tweaked"; share protocol/state/UI. |
| 2 | Push backend | **Both — FCM primary, ntfy fallback** | FCM is standard/reliable; ntfy for self-hosters with no Firebase. `ANDROID_PUSH_BACKEND`. |
| 3 | Media transport | **Over the WebSocket** | One transport, zero new Python deps; chunked binary frames. |
| 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. |
## Additional decisions made during planning
| Decision | Choice | Note |
|---|---|---|
| Connection point | **Messaging gateway** (platform plugin `android`), not `tui_gateway` | Makes cron/`send_message`/slash/coexistence native. |
| Plugin style | **Community plugin** (`register(ctx)`) | Zero hermes-core changes. |
| Python deps | **None new** (`websockets` + `httpx` are core) | Respects hermes pinning policy. |
| Tool events | **Structured frames; app controls verbosity** | Gateway sends full data; app = everything/truncated/nothing. |
| Reasoning | **Adapter splits the `show_reasoning` prefix** | Clean `reasoning` field → collapsible block above message. |
| Channels/threads | **Map onto `chat_id`/`thread_id`** | Existing gateway primitives; cron targets them. |
| Offline | **SQLite outbox + sync cursor** | Catch-up on reconnect; push on disconnect. |
| Default chat id | `android:default` | Home channel + cron default. |
| WS port | `8790` (default) | Configurable. |
| minSdk | 26 (test device API 29) | Broad coverage. |
| Frame routing | **Broadcast to all connected devices** (no per-chat subscribe) | Single-user model; simpler. |
| Initial channel load | **`history` frame** (paginated) | `sync` only replays outbox; `history` loads full messages. |
| Slash menu | **`commands.catalog` + `commands.complete`** frames | Gateway serves catalog; app renders bottom sheet + autocomplete. |
| Agent lifecycle | **`agent.busy`/`agent.idle`** events + **`agent.stop`/`agent.steer`** requests | App shows thinking indicator; user can abort or steer mid-turn. |
| Local DB (KMP) | **SQLDelight** (not Room) | Room is Android-only; SQLDelight works in commonMain for both platforms. |
| Voice input | **Record → upload as audio media** (no client-side STT) | Agent's STT (if configured) handles transcription. |
## Open questions (resolve during implementation)
These are **implementation-time** details, not blockers. Each has a default we
will proceed with unless you say otherwise.
1. **Tool-progress vs commentary classification (M2).** The exact signal that
distinguishes a tool-progress `send()` from a regular `send()`/commentary in
the legacy path. *Default:* per-chat turn-state machine + line-format
heuristic, **verified empirically** with the `ws_probe.py` harness against the
live gateway. If a clean metadata marker exists, prefer it.
2. **Reasoning prefix format stability (M2).** We split on the `code`-style
`💭 **Reasoning:**\n```\n…\n```\n\n` prefix. *Default:* set
`reasoning_style: code` for android and split on that; fallback = no
reasoning field (full text) if the prefix isn't found. Verify in M2.
3. **Per-device tokens vs shared token (M1/M5).** *Default (v1):* shared
`ANDROID_TOKEN` + optional `ANDROID_ALLOWED_USERS` device allowlist.
Per-device revocable tokens are a stretch.
4. **Desktop video backend (M6).** *Default:* `libmpv`/`mpv`-backed Compose
surface, WebView fallback. Confirm `mpv` availability on target OSes during
M6.
5. **Remote access default (M1).** *Default:* document Tailscale as the
recommended remote path; WSS + reverse proxy as alternatives. No public bind
by default.
6. **Streaming cadence (M2).** If live updates look chunky, tune
`display.platforms.android.streaming` / consumer edit interval. *Default:*
follow global streaming config.
7. **App package name / branding.** *Default:* applicationId `dev.iris.app`,
app name "Iris". Confirm final product name + package + icon.
8. **ntfy in-app listener (M5).** *Default:* a foreground service maintaining the
ntfy subscription (no extra native lib) — or a lightweight ntfy client lib if
one is acceptable. Decide in M5.
9. **Auto-update for desktop (M7).** *Default:* out of scope (manual download).
Revisit post-v1.
10. **iOS port.** Out of scope for v1 (protocol is transport-agnostic, so it's a
future port). No action now.
## Explicit non-goals (v1)
- Multi-user / group chat (personal 1-user agent).
- End-to-end encryption (transport WSS only).
- Standalone-cron delivery while the gateway process is fully down (best-effort
push only).
- iOS.
+67
View File
@@ -0,0 +1,67 @@
# Iris × Hermes — Implementation Reference Library
A coder-facing reference library for building a **native Android + Desktop
experience** for [hermes-agent](https://github.com/NousResearch/hermes-agent),
connected through a **gateway platform plugin**.
This folder is the single source of truth for *what to build and why*. Read it
top-to-bottom once, then use the numbered docs as a lookup while implementing.
> ⚠️ **READ FIRST — two hard rules**
> 1. **`hermes-agent/` (sibling of this folder) is a read-only research
> reference. It must NEVER be committed, pushed, or shipped.** It is
> git-ignored at the repo root. We only *install* our plugin into a live
> hermes install (`~/.hermes/plugins/`); we never modify hermes core.
> 2. **ADB is installed and a device is connected** (`a5ca2a4b`, Xiaomi MIX 2S,
> Android 10 / API 29). Use it to install/launch/debug the app on-device.
---
## Reading order
| # | File | When to read |
|---|------|--------------|
| 0 | [`00-overview.md`](00-overview.md) | Always first. Vision, scope, disclaimers, locked decisions. |
| 1 | [`01-architecture.md`](01-architecture.md) | Before touching code. System shape + rationale. |
| 2 | [`02-monorepo.md`](02-monorepo.md) | When scaffolding the repo. |
| 3 | [`03-gateway-plugin.md`](03-gateway-plugin.md) | When building the Python plugin. |
| 4 | [`04-wire-protocol.md`](04-wire-protocol.md) | When implementing either side of the WS. |
| 5 | [`05-streaming.md`](05-streaming.md) | Streaming / reasoning / tools / intermediate. |
| 6 | [`06-channels-cron-search.md`](06-channels-cron-search.md) | Channels, threads, cron delivery, search. |
| 7 | [`07-media.md`](07-media.md) | Media upload/download + playback. |
| 8 | [`08-push.md`](08-push.md) | Push (FCM + ntfy), outbox, sync. |
| 9 | [`09-pairing-security.md`](09-pairing-security.md) | Pairing, auth, security model. |
| 10 | [`10-android-app.md`](10-android-app.md) | When building the Android app. |
| 11 | [`11-desktop-app.md`](11-desktop-app.md) | When building the Desktop app. |
| 12 | [`12-toolchain.md`](12-toolchain.md) | First time on a machine (JDK/SDK/uv/Firebase). |
| 13 | [`13-testing.md`](13-testing.md) | Writing tests + on-device ADB workflow. |
| 14 | [`14-milestones.md`](14-milestones.md) | Planning work / tracking progress. |
| 15 | [`15-hermes-reference.md`](15-hermes-reference.md) | **Cheat-sheet** of hermes-agent source to read. |
| 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. |
Machine-readable / diagrams:
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
- [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture.
---
## The three deliverables (one monorepo)
1. **`gateway-plugin/`** — a Python hermes **platform plugin** named `android`.
Runs inside the `hermes gateway` process. Opens a WebSocket server the apps
connect to. Implements the full `BasePlatformAdapter` contract. **Zero new
Python dependencies, zero hermes-core changes.**
2. **`app/androidApp`** — native Kotlin + Jetpack Compose client.
3. **`app/desktopApp`** — Kotlin + Compose Multiplatform client that *shares*
the Android app's code and is "tweaked" for a big screen.
The Android and Desktop clients live in **one Compose Multiplatform Gradle
project** (`app/`) with a shared KMP module (`app/shared`).
---
## Status
- **Phase:** Planning complete → ready to implement (Milestone M0).
- **Owner decisions locked:** see [`16-open-questions.md`](16-open-questions.md).
- **Last updated:** 2026-08-19.
+54
View File
@@ -0,0 +1,54 @@
%% Iris x Hermes — architecture (mermaid)
%% Render with any mermaid viewer (e.g. https://mermaid.live or `mmdc`).
flowchart TB
subgraph HOST["User's machine (home server / PC)"]
subgraph GW["hermes gateway (ONE process)"]
AGENT["Agent core<br/>(run_agent.py)"]
SESS["Sessions<br/>(SQLite + FTS5)"]
CRON["Cron scheduler"]
subgraph PLUGIN["android PLATFORM PLUGIN"]
ADAPTER["AndroidAdapter<br/>(BasePlatformAdapter)"]
OUTBOX["Outbox (SQLite)<br/>+ sync cursor"]
PUSH["push.py<br/>FcmBackend / NtfyBackend"]
MEDIA["media.py<br/>cache + chunk stream"]
PAIR["pairing.py<br/>token + device registry"]
SEARCH["search.py<br/>FTS5 bridge"]
end
WSS["WebSocket SERVER<br/>(websockets) ws://host:8790/ws"]
end
AGENT -->|legacy stream callbacks| ADAPTER
CRON -->|deliver=android:chat:thread| ADAPTER
ADAPTER <--> WSS
ADAPTER <--> OUTBOX
ADAPTER <--> PUSH
ADAPTER <--> MEDIA
ADAPTER <--> PAIR
ADAPTER <--> SEARCH
ADAPTER <--> SESS
end
subgraph CLOUD["Cloud relays"]
FCM["Google FCM"]
NTFY["ntfy (self-host / ntfy.sh)"]
end
subgraph DEVICES["Clients"]
PHONE["ANDROID APP<br/>(Kotlin / Compose)<br/>WS client + ExoPlayer + FCM"]
DESKTOP["DESKTOP APP<br/>(Compose Multiplatform)<br/>WS client + tray + desktop player"]
end
WSS <-->|WSS JSON + binary media| PHONE
WSS <-->|WSS JSON + binary media| DESKTOP
PUSH -->|FCM HTTP v1| FCM
PUSH -->|ntfy publish| NTFY
FCM -->|wake (data msg)| PHONE
NTFY -->|subscribe| PHONE
classDef host fill:#eef,stroke:#333;
classDef plugin fill:#efe,stroke:#333;
classDef device fill:#fee,stroke:#333;
classDef cloud fill:#ffe,stroke:#333;
class GW,AGENT,SESS,CRON,OUTBOX,PUSH,MEDIA,PAIR,SEARCH,WSS host;
class ADAPTER plugin;
class PHONE,DESKTOP device;
class FCM,NTFY cloud;
+107
View File
@@ -0,0 +1,107 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Iris x Hermes wire protocol",
"description": "Machine-readable description of the JSON frames exchanged over the gateway WebSocket. Mirrors gateway-plugin/protocol.py and docs/04-wire-protocol.md. Media travels as binary WS frames referenced by header/end frames.",
"protocol_version": 1,
"envelope": {
"type": "object",
"required": ["v", "type"],
"properties": {
"v": { "type": "integer", "const": 1, "description": "Protocol version." },
"id": { "type": "integer", "description": "Request id; present on requests and their responses/acks. Absent on pure events." },
"type": { "type": "string", "description": "Frame type (see frame_types)." },
"chat_id": { "type": "string", "description": "Optional chat scope (e.g. android:default, android:chan_7)." },
"thread_id": { "type": "string", "description": "Optional thread scope within a chat_id." },
"payload": { "type": "object", "description": "Type-specific payload." }
}
},
"frame_types": {
"server_to_app": {
"hello.ack": {
"description": "Pairing succeeded.",
"payload": {
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "pickers": {"type":"boolean"} } },
"sync_cursor": { "type": "integer" },
"channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } }
}
},
"message": {
"description": "A final / standalone message.",
"payload": {
"message_id": { "type": "string" },
"role": { "type": "string", "enum": ["user", "assistant", "system", "cron"] },
"text": { "type": "string" },
"reasoning": { "type": "string", "description": "Optional; render ABOVE text." },
"media": { "type": "array", "items": { "$ref": "#/definitions/media_ref" } },
"reply_to": { "type": "string" },
"model": { "type": "string" },
"tokens": { "type": "integer" },
"ts": { "type": "integer", "description": "epoch millis" }
}
},
"message.start": { "payload": { "message_id": { "type": "string" }, "role": { "type": "string" } } },
"message.update": { "payload": { "message_id": { "type": "string" }, "text": { "type": "string", "description": "Full current text (app replaces)." } } },
"message.stop": { "payload": { "message_id": { "type": "string" }, "final_text": { "type": "string" }, "reasoning": { "type": "string" }, "model": { "type": "string" }, "tokens": { "type": "integer" } } },
"commentary": { "description": "Intermediate assistant beat.", "payload": { "message_id": { "type": "string" }, "text": { "type": "string" } } },
"tool.start": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "preview": { "type": "string" }, "args": { "type": "object" } } },
"tool.progress": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "note": { "type": "string" } } },
"tool.end": { "payload": { "index": { "type": "integer" }, "name": { "type": "string" }, "ok": { "type": "boolean" }, "duration": { "type": "number" }, "output_preview": { "type": "string" } } },
"typing": { "payload": { "on": { "type": "boolean" } } },
"notification": { "payload": { "kind": { "type": "string", "enum": ["channel_renamed", "channel_created", "cron", "approval", "clarify", "generic"] }, "title": { "type": "string" }, "body": { "type": "string" }, "ts": { "type": "integer" } } },
"picker.model": { "payload": { "picker_id": { "type": "string" }, "current_model": { "type": "string" }, "current_provider": { "type": "string" }, "providers": { "type": "array", "items": { "type": "object", "properties": { "id": {"type":"string"}, "label": {"type":"string"}, "models": { "type": "array", "items": { "type": "object", "properties": { "id": {"type":"string"}, "label": {"type":"string"} } } } } } } } },
"picker.choice": { "payload": { "picker_id": { "type": "string" }, "title": { "type": "string" }, "choices": { "type": "array", "items": { "type": "object", "properties": { "value": {"type":"string"}, "label": {"type":"string"}, "is_current": {"type":"boolean"} } } } } },
"picker.clarify": { "payload": { "picker_id": { "type": "string" }, "question": { "type": "string" }, "choices": { "type": "array", "items": { "type": "object" } } } },
"picker.approval": { "payload": { "picker_id": { "type": "string" }, "command": { "type": "string" }, "description": { "type": "string" } } },
"picker.confirm": { "payload": { "picker_id": { "type": "string" }, "title": { "type": "string" }, "message": { "type": "string" } } },
"channel.list": { "payload": { "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } } } },
"channel.created": { "payload": { "$ref": "#/definitions/channel" } },
"channel.renamed": { "payload": { "chat_id": { "type": "string" }, "name": { "type": "string" } } },
"channel.deleted": { "payload": { "chat_id": { "type": "string" } } },
"history": { "description": "Response to history request; page of messages oldest→newest.", "payload": { "messages": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "role": {"type":"string"}, "text": {"type":"string"}, "reasoning": {"type":"string"}, "model": {"type":"string"}, "tokens": {"type":"integer"}, "ts": {"type":"integer"} } } }, "has_more": { "type": "boolean" }, "oldest_message_id": { "type": "string" } } },
"commands.catalog": { "description": "Full slash-command catalog.", "payload": { "commands": { "type": "array", "items": { "type": "object", "properties": { "name": {"type":"string"}, "description": {"type":"string"}, "args_hint": {"type":"string"}, "category": {"type":"string"} } } } } },
"commands.complete": { "description": "Autocomplete matches for a typed prefix.", "payload": { "prefix": { "type": "string" }, "matches": { "type": "array", "items": { "type": "object", "properties": { "name": {"type":"string"}, "description": {"type":"string"}, "args_hint": {"type":"string"} } } } } },
"agent.busy": { "description": "Agent is processing; app shows thinking indicator.", "payload": { "reason": { "type": "string", "enum": ["processing", "tool", "waiting_input", "cron"] } } },
"agent.idle": { "description": "Agent turn complete; clear thinking indicator.", "payload": {} },
"search.results": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "hits": { "type": "array", "items": { "type": "object", "properties": { "message_id": {"type":"string"}, "chat_id": {"type":"string"}, "thread_id": {"type":["string","null"]}, "role": {"type":"string"}, "snippet": {"type":"string"}, "ts": {"type":"integer"} } } } } },
"media.offer": { "description": "Agent-sent media available; app pulls bytes.", "payload": { "$ref": "#/definitions/media_ref" } },
"status": { "payload": { "state": { "type": "string", "enum": ["online", "restarting", "degraded"] }, "session": { "type": "object" } } },
"error": { "payload": { "code": { "type": "string", "enum": ["auth", "not_found", "rate_limited", "media_too_large", "unsupported", "internal"] }, "message": { "type": "string" } } },
"pong": { "payload": { "ts": { "type": "integer" } } },
"sync.done": { "payload": { "cursor": { "type": "integer" } } },
"media.pull.end": { "payload": { "ok": { "type": "boolean" } } }
},
"app_to_server": {
"hello": { "description": "First frame; auth + caps.", "payload": { "token": { "type": "string" }, "device_id": { "type": "string" }, "device_name": { "type": "string" }, "caps": { "type": "object", "properties": { "min_protocol": {"type":"integer"}, "media": {"type":"boolean"}, "push": {"type":"string"} } }, "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
"message.send": { "payload": { "text": { "type": "string" }, "reply_to": { "type": "string" }, "media_refs": { "type": "array", "items": { "type": "string" } } } },
"media.upload.start": { "payload": { "media_ref": { "type": "string" }, "kind": { "$ref": "#/definitions/kind" }, "mime": { "type": "string" }, "size": { "type": "integer" }, "filename": { "type": "string" } } },
"media.upload.end": { "payload": { "media_ref": { "type": "string" }, "sha256": { "type": "string" } } },
"media.pull": { "payload": { "media_id": { "type": "string" } } },
"picker.select": { "payload": { "picker_id": { "type": "string" }, "value": { "type": "string" } } },
"channel.create": { "payload": { "name": { "type": "string" }, "kind": { "type": "string", "enum": ["channel", "thread"] }, "parent_chat_id": { "type": ["string", "null"] } } },
"channel.rename": { "payload": { "name": { "type": "string" } } },
"channel.set_default": { "payload": {} },
"channel.delete": { "payload": {} },
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" } } },
"history": { "description": "Load a page of messages (initial open / scroll-up).", "payload": { "before_message_id": { "type": "string" }, "limit": { "type": "integer", "minimum": 1, "maximum": 200 } } },
"commands.catalog": { "description": "Fetch full slash-command catalog.", "payload": {} },
"commands.complete": { "description": "Autocomplete for typed /prefix.", "payload": { "prefix": { "type": "string" } } },
"agent.stop": { "description": "Abort current agent turn.", "payload": {} },
"agent.steer": { "description": "Inject steering message mid-turn.", "payload": { "text": { "type": "string" } } },
"read.receipt": { "description": "User viewed message; server stores + broadcasts to other devices.", "payload": { "chat_id": { "type": "string" }, "message_id": { "type": "string" } } },
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
"ping": { "payload": { "ts": { "type": "integer" } } }
}
},
"definitions": {
"kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] },
"channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"} } },
"media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"} } }
},
"reliability": {
"ordering": "Per-connection (TCP/WS). message.update for a message_id is monotonic; app may coalesce to latest.",
"never_dropped": ["message", "message.stop", "tool.end", "notification", "picker.*", "channel.*", "agent.busy", "agent.idle", "history", "commands.catalog", "commands.complete", "search.results", "error"],
"coalescable_under_backpressure": ["message.update", "tool.progress"],
"offline": "Undelivered frames go to the outbox; replayed by sync. Terminal frames always outboxed."
}
}