8.2 KiB
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 achat_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(defaultandroid:default),kind=default,is_default=true, name "Default". - It is also the cron home channel (
cron_deliver_env_var= ANDROID_HOME_CHANNEL), sodeliver=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(viachannel.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 OFF — flat conversation; all messages use
- Threads are app-organized but gateway-real: each
thread_idis a distinct hermes session lane, so context is isolated per thread and cron can target a specific thread. - The gateway's
create_handoff_threadis used where hermes wants to open a named thread (e.g. continuable cron). - Threading is only active on the default channel. The topic switcher,
the "new topic" affordance (Ctrl+T), and auto-threading all apply to the
default channel only; user channels stay flat (the gateway also ignores
auto_threadfor non-default channels).
6.3.1 Auto-threading (Telegram topic-mode workflow)
With Threads ON, a message sent in a channel's flat lane (no active thread) gets its own fresh thread, the way Telegram topic mode mints a topic per new conversation — and the AI names it instead of the user:
- The app sends
message.send {…, auto_thread:true}(only from a flat lane, with non-empty text, never for slash commands). - The gateway mints a thread (
channel.create-equivalent,kind:thread) under the channel, named instantly from the user's opening message via hermes' session-title derivation (agent/title_generator.derive_title— a deterministic slice of the user's own words, no model call), and broadcastschannel.created {auto:true}. - The user echo, the agent turn, and all streaming frames carry the new
thread_id— the whole conversation lives in the thread. - In the background, the gateway upgrades the name with the model's title
(
agent/title_generator.generate_title, thetitle_generationauxiliary task) and broadcastschannel.renamed. This is hermes' two-stage session titling (derived < llm < user) applied to the thread name; failures leave the derived name in place.
App side: on channel.created {auto:true} under the flat lane it is viewing,
the app jumps into the new thread and relocates the optimistic pending
bubble from the flat lane into it (the echo arrives in the thread lane).
Follow-ups sent inside the thread stay there; the next flat-lane message
starts another thread. Media-only sends and slash commands stay in the flat
lane (nothing to title / session-scoped, not conversation starters).
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 mintschat_id = android:chan_<n>, stores in directory, broadcastschannel.createdto all devices. The new channel appears in the channel list.channel.rename/channel.set_default/channel.deletemanage the directory (rename broadcastschannel.renamed; delete is soft — marks archived, keeps history for search).- Automation channels: a channel can be marked automation
(
channel.set_automation {on}, long-press / right-click menu; the default channel cannot be marked). Automation channels are read-only for the user: they exist only to receive gateway-originated output (cron job output, webhook output). The app replaces the composer with a read-only notice, and the gateway rejectsmessage.sendinto them (error unsupported). The flag is server-authoritative (synced to all devices via thechannel.renamedfull-entry response) and shown as a gear badge in the channel list. - App affordance for threads: long-press (touch) / right-click (desktop) a
topic chip → "Rename" (
channel.rename) or "Delete" (channel.delete). Deleting the open thread falls the app back to the channel's flat lane. - Cron targeting (the key payoff): because the plugin registers
parse_target_ref_fnandcron_deliver_env_var, cron jobs and thesend_messagetool 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
cronjobtool 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
/croncreation 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). Forplatform:chat_id[:thread_id]it callstools.send_message_tool.resolve_send_target, which uses ourparse_target_ref_fnto parseandroid:<chat>[:<thread>]. - Delivery then calls the live adapter's
send(chat_id, text, …)(gateway running) → our WSmessageframe (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 "⏰ " chip) using therole:"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'ssearch.pyopens the session DB read-only and runs FTS5 queries. searchframe →search.results:scope:"all"— search everywhere (all channels/threads/sessions).scope:"chat"— restrict to the givenchat_id(and optionalthread_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).