Bot Mode | Hermes Agent
Skip to main content

Bot Mode

Bot Mode turns your Hermes profiles into a roster of named Bots. Each Bot has its own role, model, memory, skills, and avatar; Bots run recurring routines, deliberate together in group chats, and message each other directly. Build a specialist Bot once and it is there forever, one click away.

Bot Mode ships built into the desktop app and is on by default — no install needed. It appears as a Bots tab next to Sessions in the left sidebar, with a Routines tile docked beside the conversation while the Bots tab is active.

A Bot is a profile

There is no new primitive to learn: a Bot is a Hermes profile — isolated config, memory, skills, credentials, and chat history under ~/.hermes/profiles/<name>/. Bot Mode is a UI over that primitive, so everything you do in it is visible from the CLI too: hermes -p <bot> chat opens the same agent, and Bot routines appear in hermes cron list. No core patches, no background daemons, no extra storage.

See Profiles, agents, and bots for how Bot Mode relates to messaging bots and delegated subagents.

Coming from profiles?​

Your profiles keep working exactly as they did; Bot Mode adds the parts a profile alone does not have:

With plain profilesWith Bot Mode
A pile of sessions per profile; you pick one or start anotherOne permanent Bot Chat per Bot. Click the Bot and you are back in the same conversation; /new compacts it instead of forking it
Switch profiles to talk to a different specialistEvery Bot sits in one roster with its avatar, latest message, and unread state
Profiles never talk to each otherBots message each other and share group chats
Scheduled jobs live in hermes cron, apart from any chatEach Bot's routines are scheduled and edited beside its chat

Nothing moves: config, memory, skills, and credentials stay in ~/.hermes/profiles/<name>/, and hermes -p <bot> chat still opens the same agent.

The Bots pane​

The roster shows one row per agent profile: avatar, latest-message preview, and timestamp.

  • Click a Bot to land in its chat — every Bot has a canonical, persistent Bot Chat conversation that is created (and pinned) the moment the Bot is born. A row click always opens that Bot Chat (the same conversation the row previews), even when you have other tabs open for the Bot; those tabs stay open beside it. In the tab strip the Bot Chat is captioned with the Bot's name, so two open Bots are told apart at a glance.
  • Right-click a Bot → Open recent session to jump to its most recently active ordinary conversation — a cron run, a delegated job, a side thread — as a tab beside the Bot Chat. The row click itself never changes; a Bot with no other conversation yet just opens its Bot Chat.
  • Active now — the roster's activity filter includes the owner of the focused live turn, Bots that wrote within the last 90 seconds, and Bots with a recent worker heartbeat. A connected gateway alone does not mean a Bot is working.
  • Search filters the roster as you type.
  • Hide a Bot — right-click a row → Hide Bot to take a Bot you don't use out of the roster and the Active-now strip. Hiding is display-only: @mentions still resolve, group-chat memberships are untouched, and routines keep running. Once at least one Bot is hidden, an eye toggle appears in the pane header — click it to reveal hidden Bots dimmed in place, then right-click → Unhide Bot to bring one back. Hidden Bots never toast, but they accumulate unread activity silently and the eye badges a dot so you know something happened. Hidden state is saved in the Bot's profile metadata, so it follows the Bot to every desktop connected to that backend.
The canonical Bot Chat is a forever-chat

Typing /new (or /reset) inside a Bot's canonical chat would fork the relationship into a scratch session — the one thing Bot Mode promises never happens. The composer reroutes it to /compact instead: fresh working context, same conversation. Regular sessions on the same profile keep full /new freedom.

Archiving a Bot Chat from the sidebar retires it: the next click on the Bot starts a fresh conversation that becomes the new canonical Bot Chat. The retired chat stays archived and hidden — its history is preserved in the database, but it is no longer reachable from the Bot or the archive view. The automatic idle-archive sweep (sessions.auto_archive) never retires a Bot Chat; only an explicit archive does.

Organize bots into sections​

Sections are folders you make yourself — Clients, Team, whatever fits — as a second axis beside the automatic per-gateway grouping. With no sections created the roster is the plain list it always was.

  • Create one from the pane's + menu → New section, or right-click a Bot → Move to section → New section… (that files the Bot into it as you create it).
  • File a Bot by dragging its row onto a section — the target highlights while you hover, and Esc cancels the drag — or right-click → Move to section and pick one. Remove from section puts it back in Unassigned.
  • Rename, reorder, or delete a section from its heading's right-click menu (or the ⋯ that appears on hover); double-click a heading to rename. Headings fold like the gateway headings do.
  • Deleting a section never deletes Bots — they return to Unassigned, and the toast offers Undo. No confirmation is asked.

Membership — the section's id and name — is stored in each Bot's profile metadata (ui_meta), so a Bot's section follows it to every desktop connected to that backend: a second desktop rebuilds the section headings from its members the first time it loads the roster, and a rename made on one desktop reaches the others the same way. The section list itself (order, empty sections) is kept per desktop, so deleting a section on one machine leaves an empty heading on the others until you delete it there too. Bots filed before names rode along carry only the id; the desktop that created the section stamps the name onto them automatically the next time its roster loads, and other desktops pick the section up from there. When the roster shows more than one gateway, sections nest inside each gateway's bucket.

Creating a Bot​

Hit New Agent in the roster. The quick path is three fields — Name, Title, Description — and the Bot exists in seconds, introducing itself as the first message of its new Bot Chat.

An Advanced disclosure opens the full capabilities surface:

  • Clone from an existing profile — start from another Bot's config, skills, SOUL, and memory, or pick Fresh profile for a clean start.
  • Create empty — skip the bundled skills entirely for a minimal profile.
  • Model & provider pin — give the Bot its own model. Any provider/model pair Hermes knows about works, and different Bots can run on different models side by side. Leave it unset to inherit from the launch profile. Picking a model from the Bot Chat's composer sticks to that chat (it survives reopening the app) until you change the Bot's profile model, which takes over again.
  • Custom SOUL.md — the Bot's persona and standing instructions.
  • Per-skill, per-toolset, and per-MCP-server enablement — tick exactly the capabilities this specialist needs.
  • Copy API keys from the main profile — on by default. Each Bot gets its own credential store: static API keys are copied in, while single-use OAuth logins (Anthropic, OpenAI Codex, xAI) are not copied — sign the Bot in itself with hermes -p <name> auth add <provider>. See OAuth logins are shared, not copied.

Choosing which machine it lives on ("Create on")​

With more than one connection registered in Settings → Connections, the New Agent dialog grows a Create on picker. Pick a device and the profile is created on that machine's backend — your window never switches gateways. The new Bot then appears in the roster as a Connections Bot (with an @name-device handle when the name exists on several machines), and chatting with it routes to its own machine. Clone from profile then offers what exists on the target machine — its own default, or Fresh profile — rather than this window's roster; you never have to switch gateways to create a fresh Bot elsewhere.

With a single connection (the common case) the picker is hidden and the Bot is created on the machine you're connected to — exactly the old behavior.

Remote-creation notes:

  • Clone source is a profile of the target machine (its default) — a remote box doesn't have your local profiles to clone.
  • The live Capabilities tab pins to the target machine's backend, so skills, tools, and MCP servers you configure during creation land on the machine the Bot will live on. (Older desktop builds fall back to staged Skills/Tools/MCP checklists for remote targets; both read the target machine's catalog.)
  • Cancelling the dialog discards the draft profile on whichever machine it was created.

Edit Profile (right-click a Bot) reopens the same surface on the live profile any time: avatar, title, description, model pin, skills, toolsets, MCP servers, and the full SOUL.md.

Duplicate (right-click) makes a full clone of a Bot — config, skills, SOUL.md, memory, and its look. Delete Profile permanently removes one, behind the same destructive confirmation the desktop's profile menu uses; the default profile cannot be deleted.

Avatars​

Every Bot gets a face:

  • Blob faces (default) — a deterministic soft-body face drawn from the Bot's name: same name, same face, forever. While you type a name in New Agent the face follows it live; hit Randomize to re-roll, Lock face to keep the one you like even if the name changes, or pin one of the six silhouettes (round, organic, boxy, nub, cloud, sun) while everything else still comes from the name.
  • Geometric faces — the classic 7 shapes × 10 colors. During a focused live turn, the owning Bot leans and looks upward with three animated dots, then eases back to idle when the turn ends. Ownership includes the connection, so same-named Bots on different gateways do not borrow the pose. Background workers keep their existing working animation; photos, blob faces and sigils keep their own rendering.
  • An uploaded image — any picture you like.
  • An AI-generated portrait — when an image backend is configured, generated in place (this rides the standard image.generate RPC and works over both local and remote gateways).
  • A pixel pet — a companion from the petdex gallery that bounces beside the avatar while the Bot is busy. Run hermes pets in a terminal to explore the gallery.

A Bot's look, title, and description are stored in the profile's metadata on the backend, so the same Bot appears the same way on every desktop connected to that backend.

Voices​

A Bot speaks with its own profile's TTS settings (tts.* in that profile's config.yaml) on its own gateway. Read Aloud, auto-speak and voice conversation in a Bot chat — in a split tile or opened in the main pane — all synthesize through the Bot's (connection, profile), so two Bots with different voices sound different and two Bots with the same profile name on different gateways never share a voice. A Bot profile with no tts.* of its own uses that profile's server defaults; only a chat with no known owner (a plain session) uses the active profile's voice. Speech-to-text in a Bot chat still uses the active profile's STT settings.

Routines​

The Routines pane attaches recurring tasks to the Bot that does them — "summarize my inbox every morning" lives next to the Bot responsible for it. The pane docks beside the chat only while the Bots tab is active and steps aside when you switch back to Sessions (older desktop builds keep it always visible). Closing it with its ✕ hides it until you next leave and re-enter the Bots tab, when it comes back as the collapsed right-edge tab. A structured schedule picker builds the schedule (frequency first, then only the detail that matters), with an Advanced field exposing the raw Hermes schedule string.

Routines are plain Hermes cron jobs namespaced [bot:<name>] <routine> — they also show up in hermes cron list and the core Cron page. Runs land in the Bot's own chat history, so the result is right where you would talk to that Bot anyway.

Groups and group chats​

Messages sent during a member turn queue behind the active room drive, including replies in the same thread. They do not interrupt that member or mark unseen messages as read. Stop cancels queued continuations and holds the members until resumed: @mention a held member, or address the whole room (@all … / @everyone …, any wording) to release everyone — @all resume is not a required incantation. stop, halt or pause holds a member only when the word sits next to its @mention (stop @bot, @bot please pause); the same word elsewhere in the sentence is read as ordinary prose, so a German halt no longer silences the bot it was sent to — but such a message does not release a held member either (@bot please just stop now never wakes it; repeat the stop next to the mention to hold it). Stop words inside fenced code, inline code, quotes or blockquote lines are content, not directives, and never hold anyone. A held member still receives the messages its hold skipped — including the one that triggered it — on its first turn after release, so nothing addressed to it is lost. Rooms that never use "stop" as a command can turn off Detect stop directives in the group settings dialog; the Stop button keeps cancelling the active run either way. A member that is visibly still working keeps its turn for up to three hours (a member that stops reporting work expires after three minutes of silence regardless); a quiet room then watches timed-out members for another three hours after the foreground wait ends, and this observation window does not extend the turn itself. Unresolved member failures remain visible in the collapsed Activity summary after the room settles — including a turn the member's backend itself failed (bad credentials, provider errors), which is reported the moment the gateway reports it instead of looking like twenty minutes of thinking. A failure row names its cause: builder hit an error — <first line of the error> (secret-shaped tokens redacted, long lines truncated). Expand Activity for the turn sequence; re-address the member to try again. An ambiguous submit failure is not automatically resubmitted.

Right-click a local Bot → Manage groups to add or remove it from any number of group chats. Pick existing groups independently or create one inline. Local membership is stored in the Bot's backend-synced profile metadata, so it follows that profile across desktops; older profiles with one legacy group continue to work. Connections Bots join through the New Group Chat picker and remain source-qualified in the room's shared state. Groups managed by one gateway offer only Bots on the active connection, while older Desktop rooms can retain mixed-connection members.

To edit an existing room as a unit, use Manage members — from the room header's people icon or the Manage members… button in Group settings. The checklist pre-selects the current members and lists every local and Connections Bot; add or remove several at once and Save members applies the new roster (still 2–6 Bots) while the room, its history and each member's room session stay in place. A removed Bot takes no further turns; an added Bot reads the recent room history on its first turn. Cancel changes nothing.

Each member's turn carries every room message since its own last turn — up to about 200 messages or 32,000 characters of transcript, whichever fills first — with a single oversized message cut to a marked 8,000-character excerpt (the room log stores the same excerpt) rather than crowding out the messages around it; when the window still overflows, the prompt names exactly how many earlier messages were left out.

Rooms follow your gateways, not one Desktop. Each room's recent transcript, members, picture, and name are mirrored into the shared profile metadata of every gateway your Desktop is connected to, with per-gateway versioning so two Desktops writing at once merge instead of overwriting each other. Open Hermes Desktop on another machine against the same gateway (local network, Tailscale, anywhere) and the room appears with its history; gateway-only clients see it too. Rooms carry a durable internal identity, so renaming one changes just its display name everywhere, disbanding one removes it permanently on every client — even ones that were offline at the time; your Desktop remembers its own disbands, so a gateway whose copy missed the deletion cannot bring the room back — and recreating a same-name group starts a genuinely fresh room. If a gateway dies or is removed, nothing is lost: every connected Desktop keeps the full room locally and re-seeds any gateway it reconnects to. (The full orchestration log stays in each Desktop's local storage; the shared mirror is a bounded recent-history projection.)

A group's identity is editable, at creation and after:

  • New Group Chat lets you set an optional room picture alongside the name — upload one from your device or generate one (when an image backend is configured), using the same pipeline as Bot avatars. The picture appears in the roster row (in place of the default group glyph) and leads the title in the room header.
  • The room header's gear button opens Group settings, where you can rename the group or set, replace, or remove its picture any time. A rename carries everything with it — the room log, each member's room session, memberships, and the picture — so no history is lost. Renaming to a name that's already taken is rejected rather than silently suffixed.
  • Group settings also lists every member with a Compress history button. Each member keeps a hidden per-room session that grows with every turn; when it gets large enough the member starts answering with empty replies (The model returned no response after processing tool results). Compress history runs the same context compression as /compress against that hidden session — it is not reachable from the sidebar or the room composer — and reports how many messages were folded. Compress one member at a time; the room log itself is untouched.

Groups are standalone rows in the same activity-ordered roster as Bot DMs. A Bot keeps one DM row even when it belongs to several groups, while every group gets its own room row with member count, latest-message preview, timestamp, and needs-you state.

A room row organizes like a Bot row. Right-click it → Pin to top to keep a daily-driver room above the unpinned Bots and rooms (Unpin puts it back into recency order); the pin is saved with the room on this Desktop. Right-click → Move to section files the room into one of your sections — or drag the row onto a section heading — and Remove from section returns it to the group-chat bucket. A room's section is stored on its room record (rooms have no profile), so it stays local to this Desktop like the section list itself.

The room composer (and the reply-in-thread composer) starts as a single row and grows as your prompt wraps or gains Shift+Enter newlines, up to half the window (at most 24rem); past that it scrolls inside the box so the transcript keeps its space. Enter sends.

Use the Move up and Move down arrows beside a room to choose its position among rooms. Until the first move, the existing pinned-first, recent-activity order is unchanged. After a move, room order is saved on this Desktop and survives reloads; new rooms follow the explicitly ordered rooms within their pinned or unpinned band. Moves cannot cross the pinned boundary, and filtering does not discard hidden rooms from the saved order. These controls reorder actual Group Chat rooms, not user-created Bot folders, and do not change membership or gateway ownership.

Open chat on any group row (2–6 Bots) opens a shared room where the whole group coordinates:

  • One visible conversation. Public messages and each member's reply stay readable in arrival order, with the speaker's name and timestamp. Starting another topic does not collapse earlier replies. Reply in thread continues that topic without reordering the room; Activity is a secondary status view, not a replacement for messages. Private Bot Chats remain separate.

  • Your message triggers up to three serial rounds of member turns. @-mentioned Bots respond (everyone responds when nobody is mentioned); each Bot replies briefly or passes, and the room settles when a full round stays silent.

  • Teammates can hand off to the primary Bot with @hermes, including in older saved rooms; Bots on other gateways keep their device-qualified tags (for example, @default-vera).

  • Bots pull each other in with @name, and escalate real judgment calls to you with @user — the group row shows a needs you badge when that happens. Pending questions and command approvals also light that badge; resolving the last prompt clears only prompt attention, not an independent mention. A command approval in the room answers on the click itself — once, session, always or deny sends at once, with no second button to find. Prompts follow a renamed room, while disbanding retires them even if a member's in-flight poll arrives later.

  • Hard caps (10 messages per send, 3 rounds) keep rooms from spinning. On a gateway-owned room the cap pauses the discussion instead of ending it: the room shows Paused after N messages with Continue (one more budget of rounds and messages for the same discussion, up to 5 times, still available after a gateway restart) and Stop. Sending a new message in that thread also moves on.

  • Who answers a message that names nobody is a Room settings choice on gateway-owned rooms: Everyone (the default), a lead that hands the message on, or Pick who answers automatically. The automatic choice makes one short tool-less model call over the room purpose and notes, each member's role (its profile description) and the last few messages, and the room shows Assigned to X above the replies. @mentions, @all/@everyone, scheduled posts and replies inside an existing thread never reach that call, and a failed or unreadable answer falls back to everyone. Route the call to a cheap model with auxiliary.group_router (hermes model → auxiliary → Group router).

  • Each member keeps its own persistent room session, so room context survives like any other conversation.

  • Not every Bot replies to every message. Speaking is each member's own choice — a Bot replies only when it has something new to add and passes otherwise, and @-mentioning specific members scopes the round to them. Expect the members you addressed (or whoever has something to say) to speak, and the rest to stay quiet.

  • Group chats run by a gateway keep working when you close Desktop. On a connection whose gateway runs group chats (Desktop's own gateway, or a hermes serve / hermes dashboard backend reached over SSH, URL or Cloud), every new group chat is a group chat run by that gateway, whether its bots are on this computer or on others: the gateway owns its log and schedules its turns through a durable driver, so closing Hermes Desktop (or losing its connection) does not stop a discussion, and the group chat catches up from the log when you reopen it. Its header shows who is working and what the group chat waits on (approvals, replies that need attention), the group chat says when Hermes cannot run its turns, and the header offers Stop while work is active, and Rename and Disband, only where the gateway lists those methods; an approval shows the command it would allow (or, for a plugin approval rule, the plugin's description) before you choose Allow once. A message the gateway refuses before posting it comes back for editing; one that may already have been sent offers Retry, which never posts it twice; Stop also stops a message still sending. A standalone hermes serve / hermes dashboard backend forwards group chat requests when a gateway serves the profile. The root gateway fences and imports existing standalone group chats from shared-state.db into its state.db once, so those group chats have one owner and remain reachable. Without an owning gateway, standalone group chats stay with their standalone backend. Both routes support attachments; Discard appears only when the owning gateway advertises it. Older servers that do not advertise file methods hide upload and disable download. A classic group chat is one an older version saved: Desktop runs its rounds, so it pauses while Desktop is closed. Desktop no longer creates them. An existing one keeps its history and composer; one whose bots span machines keeps using the cross-connection courier described under bot-to-bot messaging, keeps that engine after membership edits, and an older group chat whose bots span connections opens in that workspace too. For a classic group chat that reaches the migration gate on a gateway connection, Desktop first opens that gateway's group chat of the same name when there is one; otherwise it does not run there. Instead it says what to do: when its bots are on this connection it offers Start new group, a fresh group chat on that gateway with the same bots that does not copy the earlier messages; when they are on other computers it names those connections, so you can switch to the one where they run and start a group there, or start a new group with bots from this connection. Its earlier messages stay readable below, read only. A connection whose Hermes is too old for group chats, or that can't be reached, says so and offers Retry.

  • Mentions read as identities. In the transcript a routed @bot mention, the human handoff @user, and the broadcasts @everyone / @all render as inline references (accent text, not pills); unknown @words and e-mail addresses stay plain. Hover a Bot's message and use Reply to @handle to seed @handle into the composer, so your next send goes to that Bot only (when a same-named Bot from a Connection shares the room, the tag is device-qualified, e.g. @reviewer-mini / @reviewer-local) — Reply in thread still continues the whole thread. If a reply box for a different thread is open, Reply to seeds the main composer instead and so starts a fresh thread; use Reply in thread to continue that thread.

  • Group chats can span machines. New group chat offers bots from every registered connection, each with a device badge. Every new group chat is owned by the active connection’s gateway and keeps running when you close Desktop; bots on other computers, including named profiles there, join through their own gateways. The dialog says “Runs on 〈owner〉, including when this Desktop is closed. Only text is sent to bots on other computers.” When a computer is not ready, it says what to change there and offers Retry; it never creates a group chat that only runs while Desktop is open. Members carry a device badge (dixie · Mac Mini) in the group chat. On a gateway that owns group chats, a selection made entirely of bots on one other connection is not created from here: add a bot from the active connection, or switch to that connection first. A gateway too old to run group chats can't create one: update Hermes there. For the endpoint and setup requirements, see bots from another gateway in a group chat. In a classic group chat an older version saved, a member’s turn that outlives its Desktop finishes on its own machine: the group chat posts that reply the next time it is driven and does not start the member again while it is still working.

  • Rooms show the same identity as the roster. Transcript rows, the “X is thinking…” line and Activity rows resolve each member's title and avatar by its owning connection and profile — re-titling a Bot or changing its picture updates every room it sits in, remote members keep their own titles, and two same-named Bots on different connections never borrow each other's avatar. When two members would still read identically, Activity appends the connection label (Reviewer · Mac Mini).

  • Plugins can watch members work. The durable room log records turn.started and turn.settled; what a member does in between (tools, approvals, streamed text) is projected to plugins through the on_room_member_activity hook with room, member and turn coordinates, so community clients can build tool cards and live member status on top of Group Chat without reading Hermes internals.

  • What a member says outside the room still reaches it. Each member's room session is an ordinary Hermes session (titled Group: <room> · <thread>), so you can resume it from the CLI, a routine can post into it, or the Bot's own tools can write to it. When you open the room, and again each time the room drives that member, whatever those writers added since the room last looked — your questions and the member's answers — is posted into the room log under that member's name, once, in the thread the session belongs to; a window restart does not repeat it. The room's own turn prompts and their replies are never duplicated this way, and neither are the agent's housekeeping rows (context compaction notes, routine deliveries, delegation results) or the member's reactions to them. The room starts watching a session the first time it sees it: what was already in that session by then is not replayed, so a room restored on a second Desktop does not re-post its members' history.

Bot-to-bot messaging​

Bots message each other with attribution, and you can hand work off from any chat:

  • @mentions — type @researcher have a look at this in any chat and the composer's @ autocomplete helps you pick the right Bot; on send, the mention is resolved against the live roster and the active Bot is told exactly who you mean (profile, friendly name, and device for cross-connection Bots). The Bot then composes its own message and sends it with message_agent — your text is never forwarded verbatim, and the reply comes back attributed to that agent. An email address or an unknown @ passes through untouched. Bots on other connected machines are reachable the same way: the Desktop relays the message over that connection's own socket (see Bots across machines below).
  • Renamed Bots keep their tags in sync — give a Bot a friendly name (the pencil in its chat header, or hermes profile rename) and it becomes taggable by that name: a Bot titled Research Buddy answers to @research-buddy (and @researchbuddy), in regular chats and in group rooms alike. The composer's @ autocomplete offers the renamed tag and also matches when you type the old profile name, which keeps resolving too. This includes the primary Bot: rename it Maia and group-turn prompts introduce it as @maia, its inter-agent messages sign as Message from 🤖 Maia (@hermes), and teammates can message_agent(target="maia") it — @hermes stays a working alias.
  • Remote Bots complete under their titles, from any chat. The @ autocomplete lists Bots on your other connected machines as soon as the Desktop is connected — you do not have to open the Bots pane first — and a remote default appears under its Bot Mode title (@cos-bot for a remote default titled CoS Bot, not a second @hermes). When two Bots would tag alike, the picker inserts the connection-qualified form (@cos-bot@<connection>), which resolves to exactly that machine's Bot. A relayed message signed Message from 🤖 hermes (@hermes@<connection>) still renders as an agent notice, not as your own text.
  • Direct messages — every Bot Chat carries the message_agent tool: a Bot messages a teammate by calling message_agent(target="researcher", message="…"). The target is the teammate's profile name, its friendly name (hermes profile rename or the Bot Mode title — Scribe, Dr. Foo) or the @-tag the Desktop inserts for it (@scribe, @dr-foo, @drfoo); @hermes always means the primary Bot. A profile name is matched first, so it can never be hijacked by another Bot's friendly name, and a friendly name shared by two Bots is refused with the roster instead of guessing. The tool validates the target against the live roster, prefixes the sender's Message from 🤖 <friendly name> (@<handle>): attribution automatically, and delivers into the teammate's canonical Bot Chat. Delivery is fire-and-forget: the sender gets a dispatch acknowledgement (status: queued plus a delivery_id — and a process_id for the background delivery process — means the message was handed to that process, not that it was delivered), finishes its turn, and that process's completion notification carries the outcome — the reply, or the delivery failure. The recipient returns its substantive answer as that same turn’s final response; it does not call message_agent merely to bounce the reply back, because the transport already captures that final response and carries it to the sender — or, when the recipient ends with an intentional silence token, delivers nothing at all. A recipient that ends silent or empty on a request that expects an answer, after writing its answer elsewhere in that turn (beside a tool call, or in a refused message_agent back), has those last words relayed once, marked [no final reply; their last message this turn:]; a notify-only message (expects_reply: false) never is. The sender can type the request: constraints (limits on the answer) and expecting (what it needs back) reach the teammate as labelled Constraints: / Reply needed: lines. Every completion leads with one outcome sentence: @beta answered., or @gamma never answered: <why> plus whether resending is safe (not sent, outcome unknown, still running or waiting on its own user, cancelled, or interrupted by its gateway's restart). Several targets in one call wake the sender once, with every teammate's sentence. On surfaces that cannot receive completion notifications (an api_server session, one-shot runners) the acknowledgement instead carries reply_delivery: poll: the sender retrieves the outcome with process(action="wait", session_id=…) before ending its turn, and the outcome is also saved into the sender's session transcript as a delivery row when the process exits, so the reply is never silently lost. The notification carries the reply whole up to the message size limit (16,000 characters); a longer one arrives as its tail and says how much was cut (process(action="log", session_id=…) has the rest). The message travels as a real parameter (nothing shell-interpreted — quotes, $(...), and backticks arrive verbatim), and the Bot composes its own message rather than forwarding your words. The teammate roster — names and roles from each profile's title/description — is part of every Bot Chat's system prompt, so Bots know who does what before choosing a recipient. The tool exists only in canonical Bot Chat sessions on Bot-Mode-managed installs; regular chats, group-room member sessions, and CLI sessions never see it.

Local messages also reach a Bot Chat that stays open in Desktop or the TUI, and so do messages relayed from another machine. The receiving backend keeps ownership: it reads durable ingress on its existing notification poller, admits immediately when idle, or waits until the running turn and already queued human prompts finish. A queued acknowledgement confirms durable admission, not a completed reply. The target profile retains the delivery ID and receipt under runtime/bot_live_delivery/; settled confirms completion. A crashed or cancelled imported turn is not automatically replayed, and pending work pinned to a departed owner remains inspectable rather than being silently rerun. Do not resend a delivery whose outcome is unknown. Older backends without live-delivery capability retain the existing ownership refusal; restart that backend after upgrading.

  • Staying silent — a Bot that has nothing to add may end a turn with one of the intentional silence tokens ([SILENT], NO_REPLY, …). The Bot Chat keeps that turn in its transcript but renders nothing, and a teammate that messaged it gets an empty reply instead of the token. Failed turns and prose that merely mentions a token are shown as-is.

  • Exchanges end on their own — two Bots cannot keep each other busy forever. A message a Bot sends back to the Bot that messaged it goes notify-only automatically (as with expects_reply: false: marked [no reply needed], no wake-up for the sender), on every route: local, another Desktop connection, or a registered peer. The Bot that receives it cannot message that sender back in the same turn. The runtime also counts every bot-to-bot exchange since your last message: inside a Bot's turn each message_agent call is one more message, and past gateway.bot_loop_guard.max_hops (default 4) a further call is refused and nothing is sent. Sending the same message to the same teammate twice in one exchange is refused as a repeat; a send that never left (the teammate's machine was offline) does not count, so retrying it later works. The count lives in the delivery itself, not in anything the model writes, and survives context compression and background reviews; your next message starts a fresh exchange. Set bot_replies_expect_reply: true to let Bots answer each other's replies (still capped by max_hops), or enabled: false to turn every rule off.

gateway:
bot_loop_guard:
max_hops: 4 # bot-to-bot messages per exchange, Bot Mode and messaging platforms
bot_replies_expect_reply: false # true: a bot's reply to a bot may be answered

The backend teaches each Bot's canonical Bot Chat session the messaging protocol automatically at prompt-build time — including when a teammate opens it headlessly from the CLI. Only the canonical Bot Chat gets the protocol section; your regular sessions and your SOUL.md stay untouched. This is controlled by agent.bot_mode_protocol in config.yaml (default: on):

agent:
bot_mode_protocol: true # inject the bot-to-bot messaging protocol into canonical Bot Chats

What actually makes a chat a Bot Chat​

agent.bot_mode_protocol is only the master switch. Before the protocol section — or the message_agent tool — is injected, two further conditions must hold, and on a desktop install the Bots pane satisfies both for you the moment it creates a Bot:

  1. The session is titled exactly Bot Chat. That exact title is the canonical chat's identity (it is what the desktop plugin's createCanonicalChat uses and what hermes -p <bot> chat -c "Bot Chat" resumes); any other title, or an untitled scratch session, gets neither the section nor the tool.
  2. At least one profile on the install carries a ui_meta: { hermes-bots: … } block in its profile.yaml. This is the "Bot-Mode-managed" marker the desktop plugin writes for every Bot it owns; the gate scans every profile, so one marked profile marks the whole install. There is no CLI command that writes it.

The practical consequence: on a headless install with no desktop app (gateway plus Telegram, say) nothing ever writes either marker, so message_agent is unreachable even though the docs-level switch is on — bots message each other fine over the messaging platform, but the agent-side message_agent tool never appears. To enable it headless, satisfy both conditions by hand:

# the canonical forever-chat, created once per Bot (later runs resume it)
hermes -p <bot> chat -c "Bot Chat" --create-if-missing
# ~/.hermes/profiles/<any-bot>/profile.yaml — an empty block is enough to mark the install
ui_meta:
hermes-bots: {}

From the next turn in that Bot Chat the teammate roster, the protocol section, and message_agent are all picked up — including over hermes -p <bot> chat on a purely terminal box.

note

Bot-to-bot delivery is per-invocation: the receiving Bot picks the message up when it next runs. Live interrupt of a Bot mid-conversation is future work.

Failed turns retry safely​

Local one-shot delivery preserves the active-session refusal code separately from its human-readable message. SESSION_NOT_OWNED produces target_busy; an unreadable coordination registry is not mislabeled as another owner. Older local CLIs without the code marker still use the historical refusal wording.

A failed delivery turn is retried at most once, and only when a retry can actually help. Transient failures (target runtime offline, delivery timeout, provider rate limit or server error) re-run the same Bot Chat session unchanged. A context-overflow failure also re-runs the same session — the retried turn compacts the over-threshold transcript via the standard context-compression pass before calling the model, so the retry fits where the original didn't. Auth, quota, and configuration failures never auto-retry: a second attempt cannot fix them and only burns quota, so the failure is surfaced immediately. A retried turn never starts a fresh session — your Bot Chat history and context stay intact. The re-run resumes the message the failed attempt already wrote into the Bot Chat instead of appending it again, so the recipient's transcript carries exactly one copy of the DM.

When a target has no live Desktop or TUI owner, local delivery opens that profile's canonical Bot Chat through a quiet CLI turn. The transport prefers the Hermes entrypoint beside the sending runtime's Python interpreter, so an unrelated or older hermes on a service's PATH cannot take precedence when that sibling entrypoint exists. --in ~ selects the working directory; the explicit Bot Chat title is resolved in the target profile's session database.

When a delivery fails: typed reasons​

A failed bot turn or relay delivery carries a machine-readable reason code alongside the human error text, end to end: the target gateway classifies the failure (provider_auth_or_access, provider_quota_limit, provider_rate_limit, provider_server_error, context_overflow, missing_config, model_unavailable, runtime_offline, queued_expired, delivery_timeout, target_busy, unknown), the Desktop forwards it, and the sending agent's completion notification is tagged [reason: <code>] ahead of the error text. A calling agent can branch on the code — "sign in again" vs "retry later" — instead of parsing provider prose. The Desktop's needs-attention badge uses the same codes.

Messaging across connected machines (the Desktop relay)​

Every gateway you register in Settings → Connections — local, remote URL, SSH, Hermes Cloud, docker — is a persistent line the Desktop holds open, and Bot Mode uses those lines for messaging automatically. No extra setup:

  • Rosters propagate on their own. While the Desktop runs, it periodically tells each connected gateway which agents live on the other connections. Every Bot Chat's teammate roster then lists them ("Teammates on OTHER connected machines"), with names, roles, and which machine they're on — and the roster refreshes when agents appear, disappear, or get renamed (capability epoch).
  • message_agent reaches them directly. A Bot on your laptop messages the cloud agent with message_agent(target="moxie", …) exactly like a local teammate. If the same handle exists on several machines, disambiguate with target="moxie@<connection>" (the tool's error tells the Bot the exact forms). Each teammate is listed by the machine's name from Settings → Connections, so a Bot reads @moxie on Homelab rather than a bare connection id. Delivery rides the Desktop: the sending gateway queues the message, the Desktop relays it to the target connection's own gateway, the target Bot runs a turn in its canonical Bot Chat — as that chat's next turn when it is open in a Desktop — and the reply comes back to the sender as the same background completion notification local DMs use (the process that waits for it is a Hermes entrypoint, so it starts under approvals.single_query_mode: deny — the default for a Bot's one-shot reply turn — too; an open chat that has not answered within the live-delivery budget reports the message queued there instead; do not resend). Messages to different Bots are delivered side by side, so one Bot's long turn never delays another Bot's mail (or ages it past bot_mode.envelope_ttl_seconds); messages to the same Bot are delivered in order, one turn at a time.
  • The Desktop is the courier. Cross-connection delivery works while a Desktop that knows both connections is running (it holds the sockets and the credentials — gateways never see each other's auth). If the Desktop is closed mid-delivery, the sender's Bot is told the reply didn't arrive rather than left hanging; a message the Desktop picked up but never handed to the target (it disconnected in between) is offered to the next drain again — once, and only after the Desktop's own delivery deadline (~26 minutes) has passed with no reply, so a slow-but-live turn is never delivered twice. The sender keeps waiting long enough for that second delivery to finish (~52 minutes in all); if it still gets no reply, the sender is told so with reason delivery_timeout. While a re-offered message waits for a drain, bot_mode.envelope_ttl_seconds applies to it exactly as to a freshly queued one, and the first reply recorded for a message is the one that stands. For always-on machine-to-machine messaging with no Desktop in the loop, register a peer (hermes peer, below) — the two routes coexist.
  • A remote default goes by its title. Every machine's default is @hermes, so a remote one is offered — and reachable — under its Bot Mode title instead (a remote default titled CoS Bot is @cos-bot; with no title, hermes@<connection>), and a DM it sends arrives signed with that same reply-safe form rather than a bare @hermes that would point back at your own default.
  • The 10-minute cap bounds the target's turn, not its handoffs. A relayed message gives the target Bot 10 minutes to finish its turn. When that turn messages a teammate itself, the delivery process stays alive afterwards — bounded by terminal.oneshot_completion_wait_seconds — so the teammate's reply can land in its Bot Chat (and, when it arrives in time, becomes the answer relayed back). That wait is never counted against the cap: a turn that finished is reported to the sender with its answer, not as delivery_timeout, and its handoff is left to complete.

Bot-initiated DMs across machines (hermes peer)​

Bots on one machine can message Bots on another machine's gateway without any desktop in the loop. Register the other gateway as a peer (its API server URL + API_SERVER_KEY):

hermes peer add spark --url http://spark.lan:8377 --key <API_SERVER_KEY>
hermes peer list
hermes peer dm spark < ~/.hermes/cache/scratch/dm.txt # message body from a file (nothing shell-interpreted)
hermes peer dm spark/researcher < ~/.hermes/cache/scratch/dm.txt # named profile on a multiplexed peer
hermes peer run spark --idempotency-key ticket-123 < ~/.hermes/cache/scratch/long-task.txt
hermes peer status spark run_abc123
hermes peer stop spark run_abc123

hermes peer dm delivers into the remote agent's canonical Bot Chat over the peer's existing API server, runs one agent turn there, and prints the reply on stdout — the exact cross-machine twin of the local hermes -p <bot> chat command.

Use peer dm only for short queries and receipts because it holds one HTTP connection until the turn finishes. If the peer takes the message but the turn outlasts that connection, the message is already in the peer's Bot Chat and the turn keeps running there, so the command says exactly that instead of reporting the peer unreachable — resending would run the turn twice. A timeout while connecting still reports the peer unreachable. When the peer's Bot Chat is open in its Desktop, the message is handed to that open chat and runs there as its next turn — whoever is watching sees it, and the reply still comes back on the call (the streaming route answers the same way, as that turn's single completion event); if that turn is still going after five minutes, peer dm reports the message as queued in that chat rather than lost. For a long turn, peer run returns a run_id immediately; poll it with peer status. If the peer's Bot Chat is open in its Desktop, the run is that chat's next turn and its status follows the chat's own receipt. The run inherits the canonical Bot Chat transcript, and a stable --idempotency-key makes a retry return the original run instead of starting duplicate work. Use peer stop with that exact run ID to interrupt it without targeting another turn.

Once a peer is registered, the messaging protocol taught to every Bot Chat (agent.bot_mode_protocol) automatically includes the peer roster, and message_agent accepts peer targets directly — message_agent(target="spark/researcher", …), or target="spark" for the peer's main agent — so your bots learn on their own that teammates exist on other machines and how to reach them. Registering or removing a peer refreshes each Bot Chat's protocol on its next message (capability epoch).

Requirements: the peer machine runs the api_server gateway platform with a strong API_SERVER_KEY; reachability is your network's business (LAN, Tailscale, VPN). The key is a credential and lives in ~/.hermes/.env as HERMES_PEER_<NAME>_KEY; peer names/URLs live in config.yaml under bot_peers.

One-way reachability (NAT)

Cross-gateway links are direct gateway-to-gateway connections — Desktop is a viewer, not a relay. A gateway behind home NAT can dial out to a public peer (laptop → VPS works), but the reverse direction has no inbound route (VPS → home fails) unless your network provides one. If your Group Chat spans a NAT boundary, put the room's authority on the host every participant can reach (typically the public VPS), or bridge the network with Tailscale/VPN.

Bots from another gateway in a gateway-owned group chat​

A group chat your gateway owns (its home) can seat a bot that lives on another gateway (its member gateway). The member's turns run on its own machine; the home keeps the group chat, its log and its schedule, even with Desktop closed. The home and member gateways must stay running. New group chat always does this for bots on other computers: every new group chat is run by a gateway. Any profile a member gateway serves can join, not only its default one. The dialog checks every computer before creating and again right before. If something is missing (Hermes too old on the owner, a member gateway without its API server, address or approvals, a computer that can't be reached), it says what to change on which computer, keeps your draft, and Create stays disabled until Retry finds everything ready. It never falls back to a group chat that only runs while Desktop is open. SSH and URL connections are eligible according to the capabilities they expose.

If setup stops after the group chat is created, it stays a gateway-owned group chat: the unjoined bot has Retry and Start new group chat without X in the header. Starting without a bot creates a separate group chat, then asks to Disband the original only after every remaining bot joins. After a lost registration reply, Desktop checks the new group chat’s state: a bot it reports as ready has joined. If the check fails, both group chats stay and Desktop says joining could not be confirmed. If a bot cannot join, use Retry in the new group chat if offered, then Disband the original when you’re ready. While a create reply is lost, pressing the action again in the same Desktop window reuses the same request, including after reconnecting. After an acknowledgement or refusal, another use of the action creates another group chat. If a member gateway cannot confirm Stop or access revocation, the original is kept and Desktop says so; both group chats remain available. A bot with a saved connection that cannot currently be reached shows offline, not not joined. Its next message tries that connection again. Retry appears for that bot only when this Desktop is connected to its computer; Start new group chat without X stays available if at least two bots remain. A Desktop-driven group chat saved by an older version keeps its history and composer; it is not converted.

While a gateway-owned group chat is open, Desktop renews peer access before its one-day dispatch grant expires (after 20 hours, or on first open after restart). Send saves its intent before waiting up to 45 seconds; Stop still works during that wait. There is no renewal loop while Desktop is closed. After more than a day idle, open the group chat with the member gateway reachable, or use Retry. The owner's dispatch renewal can extend an unexpired grant up to the advertised 30-day horizon; it cannot revive expired dispatch access. Earlier grants remain until expiry or scope revocation.

The equivalent JSON-RPC setup is below.

Limits: text only (a message with files fails that member's turn, visibly). The member can be any profile the member gateway serves: a named profile runs with its own config, secrets and sessions, and unless it sets its own gateway.room_link_url it is reached at <room_link_url>/p/<profile>. Its access works only for that profile.

These owner/lender rules apply to the canonical gateway (hermes gateway run), including when hermes serve forwards Desktop's group calls to it.

On the home gateway, groups.capabilities must report driver: true, persistent_process: true and list groups.peer.register in methods; it does not need room_link.enabled itself.

On the member gateway, groups.capabilities must report room_link.enabled: true and list groups.peer.invite in methods. That needs the api_server platform with durable run storage, an advertised endpoint (gateway.room_link_url, reachable from the home), and approvals not turned off (approvals.mode: off is refused; manual asks a person, smart may approve on its own). Otherwise room_link.reason names the first missing piece. Keep its room_link.catalog; the home pins it, endpoint included.

When a bot on the member gateway needs command approval, the home gateway now exposes its prompt in the group chat and accepts once to allow that operation or deny to refuse it. An outdated prompt cannot approve a later operation; it reports that the room approval is no longer pending.

Standalone hermes serve answering for its own group chats derives persistent_process from its lending catalog and skips API-server and endpoint checks for room_link; clients should not use it as a home for gateway-owned group chats.

  1. Create the group chat on the home with the member pinned to that catalog:

    {"jsonrpc":"2.0","id":1,"method":"groups.create","params":{"room_id":"ROOM_ID","name":"Review","members":[
    {"member_id":"host","profile":"default","handle":"host"},
    {"member_id":"reviewer","profile":"default","handle":"reviewer","target":{
    "kind":"peer","peer_id":"spark","installation_id":"CATALOG_INSTALLATION_ID",
    "profile":"default","capability_digest":"CATALOG_DIGEST"}}]}}

    Until step 3, that member's turns fail with peer room route is unavailable.

  2. Invite from the member gateway. On an operator connection there (its local gateway socket or a dashboard connection), mint a grant scoped to that one group chat and member, using the authority_gateway_id and authority_epoch the group chat was created with:

    {"jsonrpc":"2.0","id":2,"method":"groups.peer.invite","params":{"room_id":"ROOM_ID","member_id":"reviewer",
    "home_install_id":"HOME_GATEWAY_ID","authority_gateway_id":"HOME_GATEWAY_ID","authority_epoch":1}}

    ttl_seconds (60 to 86,400, default 3,600) is optional, and so is status_ttl_seconds, the renewal horizon (from ttl_seconds up to 30 days, default ttl_seconds; offered when groups.capabilities lists the peer_renewal_horizon feature). The home renews the grant when it sends that member a turn near the end of its ttl_seconds, never past the horizon. The grant id is the member gateway's to choose.

  3. Register on the home with the grant, the catalog and the catalog's own endpoint URL as target_url. The home sends the grant to no other URL. It probes the member gateway first and refuses a grant for another group chat, member, profile, catalog or URL (peer_target_mismatch), a member it cannot reach (peer_unreachable) or a revoked grant (room_reauthorization_required):

    {"jsonrpc":"2.0","id":3,"method":"groups.peer.register","params":{"room_id":"ROOM_ID","member_id":"reviewer",
    "target_url":"CATALOG_ENDPOINT_URL","target_profile":"default",
    "grant":"GRANT","catalog":{"...":"the catalog from the member gateway"}}}

A member the home sends no turn within ttl_seconds, or one past its horizon, needs access again: its next turn fails, and groups.state reports its route as needs_reauthorization under driver_status.peer_routes. To renew access, invite again and register the new grant: the route keeps its identity, so work already accepted stays the same run on the member gateway. The earlier grant stays valid until it expires. groups.peer.revoke with a grant, on the member gateway, revokes that group chat member's access. Disband also revokes retained grants, including a longer-lived grant whose replacement has expired. A scoped grant handed in during Disband is held durably and revoked before deletion commits. A late grant triggers another revoke pass; after three passes, Disband asks you to retry. An unreachable member keeps the group chat and its unrevoked holds. A grant arriving after deletion remains owed through a restart. The home retries that revoke with backoff until the member confirms it or the grant expires. If there is no space to hold another grant during Disband, registration reports the failure and keeps earlier holds. A grant that was invited but never registered is the member gateway's to revoke. When starting a new group chat without a member, Desktop waits until every remaining bot has joined before asking to disband the original. After a lost registration reply, Desktop reads the new group chat’s state once before deciding whether the bot joined. If it cannot confirm joining, both group chats stay and the notice says so. If a bot cannot join, use Retry for it in the new group chat if offered, then Disband the original when you’re ready. If Disband is refused, both stay and the notice explains why; you can Disband the original later. A lost create reply keeps the same request in memory while that attempt is uncertain. Once creation is acknowledged or refused, using Start new group chat without that bot again creates another group chat. Reconnecting or closing and reopening the original’s view in the same window keeps that uncertain request; restarting Desktop loses it. The new group chat offers Retry for bots that need it, with no saved link to the original. New group chat’s open dialog also keeps its draft through reconnects. Closing and reopening that dialog starts a fresh attempt, so check the group chat list first if its earlier create may have succeeded.

Desktop explains a refused Disband and keeps the original group chat available. If a bot profile is no longer configured, add it back to hosted_rooms.profiles so its Stop can be confirmed, then try Disband again. If revoking access fails after Stop was confirmed, the message also says that work was stopped. Other failures do not confirm this. Reconnect the member's computer, update Hermes there when asked, or wait for Stop, then retry Disband.

A lost reply never runs a turn twice. The home sends each turn under the idempotency key room:<task>:<generation>. If the member gateway's answer is lost, or its replay cannot connect, the turn stays uncertain on that same key until the home can read it back, rather than starting it again. If its status reads keep failing for 30 seconds, the home treats the turn as uncertain, then waits up to another minute for that member gateway before deferring the turn, which frees the group chat for the next message. Retry on that turn asks the member gateway for the same attempt: it posts the reply if the run finished (unless a newer message in that thread has superseded it, as for any late reply), follows the run if it is still going, and never starts another attempt. Discard of that turn ends it the way Disband does, and never starts it: the home asks the member gateway which run the turn's idempotency key started, without sending the turn again. If none, the member gateway closes that key, so a late copy of the turn is refused there, and the turn is discarded at once. If a run is found, the home stops it and settles the turn only once that gateway confirms the run ended. While the member gateway is unreachable, or too old to answer that question, the turn is kept. Desktop asks you to reconnect an unreachable computer. If its Hermes version is too old, Desktop instead asks you to update Hermes on that computer, then try Discard again. A turn still running when it reaches its time limit is stopped, as it would be on the home. This holds while the member gateway still keeps the run's record: it forgets a finished run once the grant's status lifetime has passed, so a replay after that (a Retry under a new grant much later) can start the turn again. A closed unused key survives member restarts for 30 days after closing, the issuer's maximum status lifetime, covering every grant alive then. If the close reply was lost, Retry confirms that the key is closed and cancels the same attempt. After the closed key expires, a turn still deferred at home can start under newly granted access if you explicitly choose Retry.

Stop never claims more than the member gateway confirmed. A turn is recorded as cancelled only once its gateway confirms the run ended. Stop of a deferred turn asks that gateway to stop the run the home knows of; if it cannot confirm (the gateway is down, or the home never learned the run's id), the turn stays deferred, and nothing is sent that could start it. A Stop of a running turn that its gateway does not confirm within a minute stops holding the group chat: the turn is recorded as deferred, not cancelled, and the group chat takes the next message. Disband waits for the same confirmation: it is refused (still stopping, or the member gateway is unreachable) until every member gateway confirms its turns ended, then revokes each member's grant at that gateway before the room is removed. While a member gateway is down the room and its route are kept; run Disband again once it is back. A deferred turn with a known run receipt may first wait for its exact Stop request, up to the client's 30-second timeout per member.

Transferring hosted room authority​

Authority takeover is an operator recovery procedure, not an atomic handover. Use the existing JSON-RPC methods groups.promote and groups.demote on the appropriate gateway. There are no groups.peer.promote or groups.peer.demote methods; groups.capabilities lists the methods your gateway supports.

Fence the old writer before promotion

Before sending confirm: true, establish that the previous authority cannot commit, and keep that fence in place until it has been demoted. Stop its room-writing processes and prevent automatic restart, or use an equivalent infrastructure fence. A network timeout, disconnecting Desktop, or groups.stop is not proof: the old gateway may still be running, and stopping a turn does not revoke room authority. If you cannot establish the fence, do not promote.

  1. Check replica coverage. On the replacement gateway, inspect groups.replica_state with {"room_id":"ROOM_ID"} and compare last_seq with latest_seq. Require a complete replica before planned takeover; promotion itself does not check this coverage. groups.replicate reports caught_up after ingesting pages returned by groups.log; peer registration alone does not prove the replacement has the room history. Caught-up status describes the last replicated page, not proof the old writer has stopped or that no newer events exist. For a planned move, quiesce writers, replicate through the final cursor, then maintain the fence. For disaster recovery, account for any history that never reached the replica.

  2. Promote only while the old writer is fenced. On the replacement:

    {"jsonrpc":"2.0","id":1,"method":"groups.promote","params":{"room_id":"ROOM_ID","confirm":true,"reason":"planned-handover"}}

    room_id and confirm: true are required; reason is optional and defaults to authority-unreachable. Confirmation is your assertion that the previous authority cannot commit, not a request to fence it automatically. Without confirmation the call returns error 4118. A successful result reports authority_gateway_id and authority_epoch (the replicated epoch plus one).

  3. Demote the old authority before returning it to service. Keep its normal room writers fenced while applying this RPC through a controlled recovery connection on the old gateway. Replace the example gateway ID and epoch with the exact values returned by the successful promotion:

    {"jsonrpc":"2.0","id":2,"method":"groups.demote","params":{"room_id":"ROOM_ID","observed_gateway_id":"NEW_GATEWAY_ID","observed_epoch":2}}

    All three parameters are required. Do not guess a future epoch: demotion requires evidence of a newer authority, not an invented value. It records authority.lost and adopts the observed lineage; repeating the same lineage is idempotent. If the old host is unavailable, keep it fenced and perform this step before restoring its normal writers.

  4. Verify and reconnect. Read groups.state on both gateways and compare room.authority_gateway_id and room.authority_epoch with the promotion result. Old-authority sends must be refused; direct clients to the replacement. Demotion fences writes; it does not merge histories or automatically turn the old authoritative store into a synchronized replica.

Promoting while the old gateway remains writable allows both independent state.db stores to accept messages and develop divergent histories. A higher epoch on the replacement does not remotely disable the old writer; equal epochs are not required for split-brain. If histories have already diverged, fence writers and preserve both histories for recovery rather than assuming that promotion, demotion, or replay will merge them.

Bots across machines​

When you register several backends in Settings → Connections — the local runtime, remote gateways, SSH hosts, Hermes Cloud instances — the roster shows the Bots from every connected source, persistently: SSH sources are inventoried without spawning anything on the remote box, and machines that are momentarily unreachable keep their last-known rows instead of vanishing. When the same profile name exists on several sources, handles disambiguate as @name-device (for example @research-homelab). A Bot's chats, sessions, memory, and routines live on the machine that owns the profile.

Clicking a Connections Bot does not hop your window onto that machine — stay in your chat and @mention it, seat it in a group chat, or create new agents on it directly with the Create on picker. Cloud and local agents share one roster this way: register your Hermes Cloud instance and your desktop (say, over Tailscale or SSH) and their Bots can message each other and sit in the same rooms, with each agent's work running on its own machine. Bot-to-bot DMs across those machines go through the Desktop relay automatically (see Messaging across connected machines above).

See Connecting Desktop to Many Hermes Instances for the full multi-connection guide.

Local Bots and the gateway (how many bots run at once)​

Desktop does not run a backend per Bot. One gateway process per host owns every local profile's sessions: when you open a local Bot, Desktop runs hermes gateway ensure --json for that profile, which attaches to the running gateway or starts it if nothing serves the profile yet, and then dials it over WebSocket (/api/ws) with a one-use ticket. There is no Desktop-side backend pool, no limit setting for how many Bots can be warm, and no idle reaper — opening another Bot never waits for a slot, and reading another Bot's history costs only a request to the same gateway.

The gateway's lifetime is independent of Desktop windows. Closing a window or quitting Desktop only forgets the connection; it never stops the gateway, so your messaging bots, routines, and in-flight turns keep running, and Desktop re-attaches on the next open. Stop it deliberately with hermes gateway stop. If the gateway restarts underneath Desktop (an update, a crash, hermes gateway stop followed by a later open), Desktop re-runs gateway ensure once and reconnects. A profile marked gateway.standalone: true keeps a gateway of its own; Desktop attaches to it the same way.

Remote gateways, SSH hosts, and Hermes Cloud instances in Settings → Connections are separate hosts with their own processes; their Bots run on that machine (see Bots across machines above), not on this host's gateway.

Turning it off​

Bot Mode is a bundled desktop plugin. Flip its Desktop switch off in Capabilities → Plugins → Bots — the roster, the Routines pane, and the composer middleware unregister live, no restart needed. Your profiles, sessions, and cron jobs are untouched either way; Bot Mode never owns your data, it only renders it.

There is also a preference to hide the canonical Bot Chats from the regular sidebar session list, so they only appear inside the Bots pane. (This uses the core hidden-session flag; on older gateways the chats simply stay visible.)

CLI parity​

Because Bots are profiles, everything has a terminal equivalent:

In Bot ModeFrom a shell
Chat with a Bothermes -p <bot> chat
A Bot's files, skills, memory~/.hermes/profiles/<bot>/
Routineshermes cron list (jobs named [bot:<name>] …)
Create / inspect profileshermes profile create, hermes profile list

See Profiles for the underlying primitive and Profile Commands for the full CLI reference.