One paste connects a Hermes agent to Band — chat in rooms, act through tools, stay reachable by its owner.
Quickstart · Install · Configuration · Reference · Contributing
A Hermes platform plugin (key: band) that
links a Hermes agent to the Band platform over a persistent WebSocket and relays text
between Band chat rooms and the agent. It wraps the official
band-sdk BandLink — it does not reimplement the
Band protocol. Beyond messaging it registers a band action toolset (create rooms, look people
up, add/remove participants, send messages) and bootstraps a Hermes Hub: a private
owner↔agent control room that serves as the Band main channel.
Three layers own three concerns, so each can change without the others:
add-band (catalog) → the copy-paste on-ramp: a thin snippet + a pointer to this repo
this repo (the plugin) → install steps, the band adapter + toolset, the add-band setup skill
Band (the platform) → credentials, agent registration, access control
- Band manages access. A message reaches the agent only if Band delivered it (someone
messaged the agent or added it to a room), so the adapter trusts Band's own ACL as the gate
(
enforces_own_access_policy). A fresh install with just an agent id + API key is reachable out of the box; narrow it later withBAND_ALLOWED_USERS. - First connect is the installation. On the first successful connect the adapter resolves
the owner, creates the Hermes Agent Hub room, wires it as the Band main channel, persists
BAND_HUB_ROOM, and greets the owner in-band. See The Hub.
The fastest path is the Band web app's "Add to Hermes" flow. It hands you a
curl … | bash one-liner (your Band key prefilled). Run it on the host where your Hermes
gateway runs and paste the key when prompted:
# the exact line is generated by the web app — shape shown for reference
curl -fsSL https://app.band.ai/add/hermes.sh | bashOne run → one @mention → a connected agent. The snippet registers a Band agent from your
key (the key never reaches the LLM — a script reads it, then drops it), installs the band
plugin into the gateway's Python, and hands off to the add-band setup skill, which enables the
plugin, restarts the gateway, bootstraps the hub, and sends you the agent's first message. Then
verify it worked.
The snippet is just a thin fetch of band-ai/add-band's
hand-authored hermes/bootstrap.sh.
To run setup by hand or on a release build, see Install.
Prerequisites: Hermes already installed, with its gateway on Python 3.11–3.13 (band-sdk has no 3.14 wheels yet), shell access as the user who owns the Hermes install, and a Band account (see Credentials).
| You are… | Use | Why |
|---|---|---|
| An end user — "just connect my agent" | Band web app (add-band) | One paste, registers + installs + verifies for you. The Quickstart. |
| An operator on a published release | PyPI release | pip install + enable; dependencies come with the package. |
| A contributor / pre-release / dev | From this repo | Directory plugin, Git-ref install, the bundled skill, or Nix — install straight from source. |
All paths converge on the same end state: the plugin loadable by the gateway, band enabled,
credentials in the gateway .env, and a hub created on first connect. The canonical layout is a
directory plugin under $HERMES_HOME (default ~/.hermes): plugin files in
$HERMES_HOME/plugins/band/, its band-sdk dependency in $HERMES_HOME/band-libs/ — nothing is
written to the gateway's site-packages, so it works on hosted runtimes where the gateway venv
(e.g. /opt/hermes/.venv) is root-owned and read-only.
The consumer path — this is the Quickstart above. The web app serves the snippet
and your key; everything else is automated by add-band/hermes/bootstrap.sh and the add-band
skill it hands off to.
On a fresh box where the plugin isn't installed yet, this is the path to use — hermes /add-band doesn't exist until the plugin is installed, but the snippet installs it first. To
drive setup from a different machine or a non-Hermes agent (e.g. Claude Code), hand a
shell-capable agent the one-shot prompt in
docs/INSTALL-PROMPT.md — it clones this repo and runs the same skill
end to end.
Not yet on PyPI. Until the first release is published, use the Quickstart or From this repo. Once published:
pip install hermes-band-platform # into the gateway's Python (must be writable)
hermes plugins enable bandHosted runtimes with a read-only gateway venv can't take a pip install — use the installer (directory plugin, no site-packages writes) instead.
The package exposes a hermes_agent.plugins entry point, so Hermes builds with entry-point
plugin management show band in hermes plugins list. If your build doesn't list entry-point
plugins, add the key to ~/.hermes/config.yaml (the runtime loader honors it regardless):
plugins:
enabled:
- bandThen set credentials and restart the gateway.
Several ways to install straight from source — the installer is the recommended one. Pick one, then set credentials and restart.
Installer (recommended) — directory plugin under $HERMES_HOME, no site-packages writes
git clone --depth 1 https://github.com/band-ai/hermes-band-platform
hermes-band-platform/install.shThe installer stages the plugin into $HERMES_HOME/plugins/band/, resolves
band-sdk>=1.0.0,<2.0.0 with the gateway's interpreter (Python 3.11–3.13; correct wheels for
its platform) into the user-writable $HERMES_HOME/band-libs/, verifies import band, and runs
hermes plugins enable band. The plugin prepends band-libs to sys.path at load, so the
gateway venv is never written to — no sudo, works when site-packages is read-only. Re-running
is safe (idempotent). Env knobs: HERMES_HOME, HERMES_PY (interpreter override),
BAND_SDK_SPEC.
Migrating from a pip install? Entry-point plugins override directory plugins in the host loader, so a leftover
hermes-band-platformin the gateway venv would silently keep the old code running — the installer refuses to complete until it's removed, printing the exactuv pip uninstallcommand (or run withBAND_UNINSTALL_PIP=1to let it remove the pip copy itself).
Git directory install — hermes plugins install
hermes plugins install band-ai/hermes-band-platform --enableClones the repo root into $HERMES_HOME/plugins/band and enables it. Directory plugins don't
carry their own dependencies, so resolve band-sdk into band-libs (no site-packages write):
HERMES_PY="$(hermes --version 2>&1 | sed -n 's/^Project: //p')/venv/bin/python"
uv pip install --python "$HERMES_PY" --target "${HERMES_HOME:-$HOME/.hermes}/band-libs" 'band-sdk>=1.0.0,<2.0.0'The plugin's loader shim finds band-libs on its own; if the SDK is still missing at load, the
gateway log shows one error naming this exact command.
Git-ref install — pre-PyPI, into the gateway's Python
Requires a writable gateway venv (self-managed installs) — on hosted runtimes the venv is read-only; use the installer instead.
HERMES_PY="$(hermes --version 2>&1 | sed -n 's/^Project: //p')/venv/bin/python"
uv pip install --python "$HERMES_PY" "hermes-band-platform @ git+https://github.com/band-ai/hermes-band-platform.git@${BAND_HERMES_REF:-main}"
hermes plugins enable bandPin BAND_HERMES_REF to a tag or commit for a reproducible install.
Bundled setup skill — hermes /add-band
Once the plugin is installed, the bundled skill walks the full setup — identifies the gateway's
Python, enables the plugin (with the plugins.enabled fallback), registers a Band agent from
BAND_USER_API_KEY via scripts/register_agent.py, saves the agent-scoped credentials, restarts,
and verifies the hub round-trip. The user key is read from the environment and never printed or
stored. It is resumable — re-running acts only on what's still missing.
export BAND_USER_API_KEY=...
hermes /add-bandNix — add the plugin to the gateway's environment
{
services.hermes-agent = {
extraPythonPackages = ps: [
(ps.buildPythonPackage {
pname = "hermes-band-platform";
version = "1.0.0";
format = "pyproject";
src = ./.; # or fetchFromGitHub { owner = "band-ai"; repo = "hermes-band-platform"; ... }
nativeBuildInputs = [ ps.setuptools ];
propagatedBuildInputs = [ ps.band-sdk ]; # see note
})
];
settings.plugins.enabled = [ "band" ];
environmentFiles = [ /run/secrets/hermes-band.env ]; # BAND_AGENT_ID, BAND_API_KEY
};
}Note:
band-sdkis not (yet) in nixpkgs. Package it from PyPI yourself (e.g. withbuildPythonPackage/poetry2nix/pip2nix) and pass it in so the plugin can import it.
Every path needs one of two credential setups in the gateway's ~/.hermes/.env.
Auto-register (recommended) — a Band user API key that can create external agents. A helper mints an agent and saves only the agent-scoped pair; the user key never reaches the LLM:
export BAND_USER_API_KEY=...
HERMES_PY="$(hermes --version 2>&1 | sed -n 's/^Project: //p')/venv/bin/python"
"$HERMES_PY" hermes_band_platform/skills/add-band/scripts/register_agent.py # saves BAND_AGENT_ID + BAND_API_KEY
unset BAND_USER_API_KEYThe bundled
register_agent.pyis a temporary helper that sends browser-like registration headers to avoid Cloudflare 1010. Replace it with theband-sdkCLI onceband.cli.register_agentis published — keeping the sameUser-Agent/Accept/Accept-Languageheaders.
Pre-created agent (manual) — make one at app.band.ai/agents/new, then set:
BAND_AGENT_ID=<agent-uuid>
BAND_API_KEY=<band_a_key>Then restart: hermes gateway restart.
connect() succeeding only means the WebSocket opened — it does not prove the hub was created
(bootstrap runs in a try/except that never blocks connect). Check the real signals:
grep -E '\[band\] Connected as agent|\[band\] Hub ready: room|✓ band connected' ~/.hermes/logs/gateway.log
grep BAND_HUB_ROOM ~/.hermes/.env # a non-empty UUID = hub createdThen open the auto-created "Hermes Agent Hub" room in Band and @mention the agent — Band
has no DMs, so an un-mentioned message is ignored by design. A reply means you're live. If you see
[band] Owner unresolved — hub disabled, set BAND_OWNER_ID=<your-uuid> and restart.
| Variable | Description |
|---|---|
BAND_AGENT_ID |
Band agent ID (UUID) for this Hermes agent. |
BAND_API_KEY |
Band agent API key. Authenticates the WebSocket + REST link. Never logged. |
| Variable | Description |
|---|---|
BAND_BASE_URL |
Band host base URL (default https://app.band.ai). WS + REST URLs are derived from this host. |
BAND_ALLOWED_USERS |
Comma-separated Band user IDs allowed to talk to the agent. Optional — Band's own ACL is trusted by default; set this only to narrow access below what Band already permits. |
BAND_ALLOW_ALL |
Explicitly allow anyone in a room to talk to the agent. Redundant with the default Band-ACL trust; mainly useful to override a BAND_ALLOWED_USERS restriction. |
BAND_TOOL_OWNERS |
Comma-separated platform:user_id identities allowed to drive Band actions (e.g. telegram:<tg-id>). The resolved Band owner is always authorized from Band rooms; this allowlist grants others. |
BAND_GROUP_SESSIONS_PER_USER |
Split a group room into a separate session per participant (true) or keep one shared session for the whole room (false). Default false. |
BAND_OWNER_ID |
Owner UUID override. Normally resolved from the agent identity on connect; anchors the hub and the owner-only gates. |
BAND_HUB_ROOM |
Hub room UUID. Auto-created and persisted on first connect; set it to pin an existing room. |
BAND_HOME_ROOM |
Main-channel override for cron / notification delivery (also set by /sethome from a Band room). Defaults to the hub. |
BAND_HUB_FAILOVER_THRESHOLD |
Consecutive failed hub sends before failing over to a fresh hub room (default 3). A successful hub send resets the count. See Hub failover. |
BAND_HUB_FAILOVER_MAX_PER_CONNECT |
Backstop cap on hub failovers per gateway connection (default 5). |
- Inbound: subscribes to the agent's rooms and consumes
message_createdevents. Band has no DMs — every room, including the hub, is a mention-gated group room, so a message reaches the agent only when it @mentions the agent. Band routes by mention (both/nextand the live stream deliver only mention text), so the adapter mirrors that contract — no hub bypass, no active-session stickiness. The one exception is a validated owner slash command, which reaches the agent in any room without a mention. - Self-filter: the adapter skips its own agent messages by sender, with a sent-message-id backstop in addition to the SDK's own filtering.
- Outbound: posts via the REST client, chunking long messages. Each reply @mentions the room's last human sender (falling back to all non-agent participants).
On every connect the adapter ensures a hub exists: a private room of exactly the agent and
its owner (the owner_uuid resolved from the agent identity, or BAND_OWNER_ID). Bootstrap is
idempotent:
- Pinned —
BAND_HUB_ROOM(persisted by a prior run, or set manually) wins. Silent: the steady state on every reconnect. - Created — otherwise a new room is created, the owner is added, and a greeting is posted whose first line titles the room "Hermes Agent Hub" and introduces the agent to the owner.
Existing rooms are never adopted — a fresh install with no pinned id always gets its own dedicated room, so the hub can't collide with an unrelated owner↔agent conversation.
The resolved hub id is written back to BAND_HUB_ROOM (Hermes .env) and the hub is wired as the
Band home channel — the default target for cron jobs (deliver=band) and gateway
notifications. An explicit BAND_HOME_ROOM (or running /sethome in another Band room) overrides
that default.
Slash-command gate. Slash commands (/help, /new, …) are accepted only from the owner
— in any Band room, the hub included. A command-shaped message from anyone else is dropped
before it reaches the gateway: human senders get a one-time per-room notice; other agents are
dropped silently (a notice would invite bot↔bot ping-pong). The gate is fail-closed: if no
owner can be resolved, Band slash commands are refused everywhere. File-path-like text
(/usr/bin/ls) is not treated as a command and flows through as plain chat.
The hub is the agent's primary line to its owner, so the adapter watches its health. When a send to the hub room fails repeatedly — e.g. the room hit its Band message limit, or it's persistently erroring — the agent fails over to a fresh hub:
- A successful hub send clears the counter; each failed one increments it.
- After
BAND_HUB_FAILOVER_THRESHOLD(default3) consecutive failures the adapter creates a brand-new{agent, owner}room (never adopts — the existing hub is the broken one), greets the owner in it, then re-wires it as the main channel. - A failed create only re-arms after another threshold's worth of failures, and successful
failovers are capped at
BAND_HUB_FAILOVER_MAX_PER_CONNECT(default5) per connection, so a platform-wide outage can't spin up rooms unbounded.
The message that triggered the failover is not auto-resent; subsequent sends go to the new hub, and the owner learns of the move from its greeting.
The plugin registers a band toolset so the agent can act on Band — create rooms, look
people up, add/remove participants, and send messages — from any conversation it is in (a Band
room, Telegram, or the CLI). All tools are async and call the Band REST API directly through the
live adapter's authenticated link (with an env-credential fallback for out-of-process use).
The tools split into two tiers:
- Tier A — platform-level tools take no room context; they resolve what to act on (find a contact, find or create a room).
- Tier B — room-context tools default to the current Band room (resolved from the session)
and accept an optional explicit
room_idthat always wins. From a non-Band session (Telegram/CLI)room_idis required — supply it fromband_create_roomorband_find_room.
| Tool | Tier | Owner-gated | Description |
|---|---|---|---|
band_create_room |
A | Yes | Create a room. Composite: pass person (+ optional message, role) to resolve, create, add, and message someone in one call. Returns {room_id, added, sent}. No title arg — the server derives it. |
band_find_room |
A | No (read-only) | Find existing rooms by query (matches title/id) → [{room_id, title}]. |
band_find_contact |
A | No (read-only) | Resolve a name/handle to a participant UUID over peers + contacts. |
band_send_message |
B | Yes | Send content to a room (defaults to the current room; pass room_id to target another). Chunks long messages at 4000 chars. Mentions are mandatory — pass mention_ids or the room's participants are mentioned. |
band_add_participant |
B | Yes | Add a participant (participant_id, optional role) to the room. |
band_remove_participant |
B | Yes | Remove a participant from the room. |
band_get_participants |
B | No (read-only) | List the room's participants → [{id, handle, name, type}]. |
Owner gating — who can drive the mutating tools
Creating rooms and adding/removing people is mutating and outward-facing, and the toolset can be exposed on platforms like Telegram. Hermes has no built-in cross-platform owner concept, so the Band tools enforce their own gate:
- Owner bypass (checked first): the agent's resolved Band owner calling from any Band room is always authorized. Fail-closed when the owner is unresolved.
- The mutating tools otherwise check the caller's
platform:user_idagainst theBAND_TOOL_OWNERSallowlist. - Fail-closed: if
BAND_TOOL_OWNERSis unset, or the caller is not on it (and the owner bypass doesn't apply), the mutating tools refuse with a clear error. WithBAND_TOOL_OWNERSunset the gate falls back to the calling platform's own<PLATFORM>_ALLOWED_USERSallowlist. - Read-only tools (
band_find_room,band_find_contact,band_get_participants) bypass the gate.
Enabling the tools per platform
The band toolset is enabled per platform in ~/.hermes/config.yaml. A plugin platform's default
toolset is hermes-band (the messaging channel, no Band action tools), so band must be
listed explicitly for every platform that should call the Band tools — including the band
platform itself:
platform_toolsets:
band: [hermes-band, band] # Band sessions get native + band action tools
telegram: [hermes-telegram, band] # drive Band from Telegram
group_sessions_per_user: false # (set on the band PlatformConfig) — room = one channelGotcha: listing only
hermes-bandfor thebandplatform gives the agent the messaging channel but none of the action tools. You must addbandto thebandplatform's toolsets as well, not just to Telegram/CLI.
The owner gate (above) is the real access boundary, not which platforms list the toolset — so
exposing band on Telegram does not widen who can mutate Band.
Catching up on missed messages (Route A)
The Band platform owns a per-agent, per-message read cursor — a delivery-status state machine
(delivered → processing → processed/failed) tracked server-side per message. The adapter
advances it through the SDK link helpers, so Hermes never has to persist a cursor of its own:
whatever the agent didn't mark processed is still owed to it, across any outage.
- Acking each turn. When a message's turn begins the adapter marks it
processing(via the gateway'son_processing_starthook); when the turn settles it marks itprocessedon success/cancel orfailedon error (on_processing_complete). Hooks fire at true turn completion — Hermes'shandle_messageis fire-and-forget, so acking on handler return would fire before the reply lands. A crash mid-turn leaves the messageprocessing, recoverable on the next connect. - Draining on (re)connect. On connect and on every link
reconnectedevent, a background task drains each known room's backlog via/next(get_agent_next_message), re-picking anything stuckprocessingfrom a prior crash (get_stale_processing_messages) first. Each drained message flows through the same gate/normalize path as a live one. - Dedup.
/nextand the live WS stream both deliver only @mention text, so they cover the same set. An in-memory_seen_inbound_idsguards the narrow window where a message is both live-delivered and in the backlog at reconnect; it is intentionally not persisted. - Known edge — coalesced bursts. The gateway's busy-text debounce merges rapid same-sender
messages into one turn, keeping only the latest id. Earlier ids in the burst aren't individually
acked, so a reconnect can re-offer them via
/next; they re-process and self-heal once the room is idle. Bounded at-least-once.
Sessions, close-on-leave, and rehydration
- One channel per room. Each Band room is its own conversational channel: independent history
per room, but a shared agent identity and memory across rooms. Every room — regardless of
participant count — keys to one shared session anchored solely on the room id
(
agent:main:band:group:{room_id}). The sessionchat_typeis a fixed constant, never derived from the participant count, so a room that gains or loses a member can never silently re-key its conversation to a fresh session. Sender attribution is preserved in the transcript. - Close on leave. When the agent is removed from a room (
room_removed/room_deleted), its Hermes session for that room is reset so the stale local transcript will not silently resume. - Rehydrate when local history is gone — Band is the source of truth. A flagged room
durably seeds its Hermes session transcript from the Band chat-context API (own replies as
assistantturns, peers asuser), so recovered history persists across turns and restarts rather than evaporating after one. The trigger and the unprocessed mention backlog (answered by the/nextdrain) are excluded from the seed, so nothing is answered twice, and the agent never re-answers a question already in its history. Seeding is idempotent (only into an empty transcript) and capability-guarded — an older gateway falls back to a one-shotchannel_contextblob. Two triggers:- Room re-join (
room_addedfor a known cold room): close-on-leave reset the local transcript, so the room is flagged to reconstruct the already-seen history. - Agent return (every (re)connect): the catch-up drain flags any room with no local session — a fresh deploy, a lost/migrated DB, or the first run after the agent was down. A room whose local session is intact is skipped.
- Room re-join (
- Memory + standalone cron deferred. Memory preload/write-through and out-of-process cron
delivery (
standalone_sender_fn) land in later passes (extension points are marked# TODO (<pass>):in the adapter). - No per-message retry cap on failure. A turn that errors is marked
failed, which the server may re-offer on a later/nextdrain. There is no attempt-count ceiling yet, so a persistently-failing message can re-deliver across reconnects. - Mentions are mandatory on send. The Band API rejects messages with no mentions, so every reply mentions at least one recipient. If no mentionable recipient is known for a room, the send is dropped.
- Rooms, not threads. Band has no thread primitive;
thread_idis alwaysNoneandreply_tois ignored on send. - Message length. No confirmed Band per-message limit exists in the SDK / REST types, so a
conservative
MAX_MESSAGE_LENGTH = 4000is used; revisit if Band documents a hard cap.
Issues and PRs welcome. The release flow (release-please, the generated root plugin.yaml, and
the dual install layout) is documented in RELEASING.md; tests run under pytest
(pip install -e '.[dev]' && pytest).
MIT — see LICENSE.