1234567891011121314151617Employees ── WhatsApp / Telegram ──┐ (chat is the only user surface) ▼ ┌─ AGENTS BOX — one VPS, Docker ─────────────────────────────┐ │ ┌─ container: alice ─┐ ┌─ container: bob ─┐ ┌─ carol… ─┐ │ │ │ hermes gateway │ │ hermes gateway │ │ │ │ │ │ + agent │ │ + agent │ │ │ │ │ │ /vault = alice/ │ │ /vault = bob/ │ │ │ │ │ │ .brain index + MCP │ │ .brain index+MCP │ │ │ │ │ └────────────────────┘ └──────────────────┘ └──────────┘ │ └───────────────▲─────────────────▲──────────────────────────┘ │ git over SSH — the only link between the boxes, │ per-person deploy keys, one key → one repo ▼ ┌─ BRAIN BOX — master host, AGENT-FREE ──────────────────────┐ │ master vault ─→ brain cycle (cron) ─→ compiled/<person> │ │ promotion queue · brain doctor · localhost dashboard │ └─────────────────────────────────────────────────────────────┘
brain CLI, nothing else. No LLM ever runs here, so
nothing here can be talked into leaking. 2 GB of RAM is plenty.Inbox/, share drafts to People/<me>/Promotions/. This works even when the brain box is down.git push — the entire client interaction. Pushes are accepted as-is; validation is asynchronous.brain cycle (cron, every few minutes) validates every pushed path server-side, applies what's legal, reverts the rest on the next compile (fail closed); provisions any self-service client requests and routes share requests (queuing shares, auto-applying revokes); sweeps promotion drafts into the pending queue; recompiles every vault; and finally routes actionable brain doctor findings to each responsible person as a rolling Inbox digest (brain triage) their agent fixes through the same gated paths.git pull — the verdict returns: rejected edits vanish, approved shared knowledge arrives.| Transport | Brain box exposure | Choose it when |
| Tailscale (or any WireGuard-based mesh) | No public ports | You can install it on both hosts. Nice bonus: the same network carries your admin SSH and the dashboard tunnel. |
| Provider private network | No public ports | Both hosts sit at the same provider and region — the private LAN is free and there's nothing extra to install or trust. |
| Public SSH + deploy keys | One public port (22), keys only | The hosts can't share a private network — agents on a managed platform, a different provider, or anywhere you can't install a VPN. The forced-command restriction does the same work either way. |
| GitHub / Gitea as the hub | None — the brain box only dials out | You'd rather not operate SSH at all. One private repo per person, per-repo deploy keys; both boxes push and pull against the forge. Costs: company knowledge rests at the forge, and sync becomes polling-shaped. |
| Headcount | Agents box RAM |
| ~5 | 4 GB |
| ~15 | 8 GB |
| ~30 | 16 GB |
| size | vs. master content | |
| master content | 1.05 MB | 1× |
| all 10 compiled vaults | 9.2 MB | 8.8× |
…after --index | 13.8 MB | 13× |
| per vault | |
shared content (Company/) | 252 KB |
| own private content | 88 KB |
.git | 500 KB — 57% |
.brain/ index (with --index) | +432 KB |
everyone multiplies its cost by headcount..brain/ is disposable. It is rebuilt from the vault by the next brain index, is never backed up, and costs nothing to delete.rm -rf a compiled vault to reclaim space. It is not a derived
artifact you can regenerate — it is the git remote that person's clone
pushes to. Deleting it throws away any commits pushed since the last cycle
(write-back reads them from there), and the recompiled replacement is a new
repo with unrelated history, so every existing clone breaks and has to be
re-cloned. If a vault genuinely must be rebuilt, run a cycle first so
pending edits reach master, then re-clone on the person's side.state.db, so a conversation started on WhatsApp continues in the browser or desktop app — same brain, different doors.| Surface | What the employee gets | Setup | Risk surface |
| Chat (default) | Capture, ask, share — the full agent | Pairing code, once | Nothing new |
| Brain dashboard | Read-only view of their own knowledge: what's new, search, the graph, a capture box | None — a URL | Lowest: read-only by construction |
| Hermes web UI | Browser chat with streaming, tool activity, sessions (hermes serve) | None — a URL | Full agent control — gate it well |
| Hermes Desktop | Native app attached to their container: chat, files, voice | Laptop install + sign-in | Same as web UI |
hermes serve, and the user signs in under Settings → Gateway.hermes serve means remote control of that agent. Credentials must be
per-person, per-container — never shared — and the auth gate must map each
identity to exactly one container. Pilot: keep visual surfaces reachable only
over your private network (tailnet or equivalent) or an SSH tunnel. Wider
rollout: one SSO reverse proxy (OIDC / Nous OAuth) with per-person routes.
The brain dashboard has no auth of its own — off-loopback it must sit behind the
authenticated proxy.brain cycle validates and applies it to the master and recompiles her vault.Shares.md note (generated into her vault) now lists it as awaiting approval — and will show the decision, including a rejection reason, once made.1234567# e.g. with Tailscale, on both boxes: curl -fsSL https://tailscale.com/install.sh | sh tailscale up # names: brain-box, agents-box # or with a provider private network / public SSH: no install — # just make "brain-box" resolve on the agents box: echo "10.0.0.2 brain-box" >> /etc/hosts
org.yaml / spaces.yaml details):12345uv tool install git+https://github.com/joedanz/brainkit # brainkit is not on PyPI brain init /srv/brain/master --company "Acme Co" brain compile --master /srv/brain/master --out /srv/brain/compiled brain doctor --master /srv/brain/master --out /srv/brain/compiled adduser --disabled-password brain-sync # per-person keys, one repo each
12345678910111213# crontab — $CYCLE_HC / $DOCTOR_HC are healthchecks.io-style check URLs # flock -n -E 75: one cycle at a time; a tick that finds the lock held exits # 75 and pings neither endpoint (see below) */5 * * * * flock -n -E 75 /var/lock/brain-cycle brain cycle \ --master /srv/brain/master --out /srv/brain/compiled --index --json \ >> /var/log/brain/cycle.jsonl 2>&1; rc=$?; [ $rc = 75 ] || \ { [ $rc = 0 ] && curl -fsS -m 10 $CYCLE_HC >/dev/null \ || curl -fsS -m 10 $CYCLE_HC/fail >/dev/null; } 17 3 * * * brain doctor --master /srv/brain/master \ --out /srv/brain/compiled >> /var/log/brain/doctor.jsonl 2>&1 \ && curl -fsS -m 10 $DOCTOR_HC >/dev/null || curl -fsS -m 10 $DOCTOR_HC/fail >/dev/null 7 * * * * /usr/local/sbin/backup-master.sh \ >> /var/log/backup-master.log 2>&1 # encrypted offsite backup
cycle means a held edit or failed write-back, a tampering signal, or a
vault that failed to compile (the output names which); a nonzero doctor
means an integrity error. Ping the check on success and its /fail
endpoint on failure (a dead-man's switch), rather than only pinging on
failure — a box that dies or a cron that stops firing can't report its own
failure, but a missed success ping still alerts. Give each job its own
check, with the period matched to that job's cadence (cycle: 5 minutes;
doctor: 1 day; hourly backup: 1 hour) plus a small grace. The 5-minute
cycle check doubles as box-liveness monitoring.brain cycle takes no lock of its own, so flock supplies one: two runs
at once would both write master and rebuild the same vaults through the
same swap directories. -E 75 is what keeps the lock from lying to your
monitoring. Without it a tick that finds the lock held exits 1, which the
dead-man's switch reads as a failed cycle — so a merely-busy box pages
someone. With it, a skipped tick is distinguishable and pings neither
endpoint: an occasional skip stays silent, while a cycle that wedges for
good stops producing success pings and trips the check on its own. If
skips become routine, the cycle has outgrown its interval — lengthen it,
or find out what got slow.brain-liveness.sh
watches this box's long-running services (every enabled brain-* systemd
unit — dashboards, webhook receiver — plus $EXTRA_UNITS such as a
fronting proxy), and
agents-liveness.sh
watches the agent-container fleet. Each pings one check per box: success
while everything runs, /fail naming the dead units or containers
otherwise.--index builds semantic
(not just keyword) indexes. All four
BRAIN_EMBED_* variables must
reach the cycle's environment — and any long-running brain dashboard
services, which resolve the provider once at startup. A single env file
covers both (systemd units add EnvironmentFile= pointing at it):123456789# ~/.config/brain/embed.env — KEY=value lines, 0600 BRAIN_EMBED_BASE_URL=https://ai-gateway.vercel.sh/v1 BRAIN_EMBED_API_KEY=... BRAIN_EMBED_MODEL=voyage/voyage-4 BRAIN_EMBED_DIM=1024 # crontab: source it before the cycle line (inside the flock, as above) */5 * * * * set -a; . $HOME/.config/brain/embed.env; set +a; \ flock -n -E 75 /var/lock/brain-cycle brain cycle ...
BRAIN_EMBED_DIM:
some gateways ignore the dimensions parameter for some providers (the
voyage models above always return their native 1024, whatever you ask
for), and the value you configure must match what the endpoint actually
returns.brain cycle --index only touches changed files and has no full-rebuild flag. After
enabling the provider, run a one-time
brain index --vault <compiled/person> --full
for each compiled vault (and inside each agent container against
/vault); from then on the cycle keeps vectors current incrementally.brain init scaffolds a commented
_meta/webhook.yaml.example next to
org.yaml; copy it to webhook.yaml, fill in your sources, and run
brain webhook on this box as a supervised
service with a TLS reverse proxy in front of its loopback port. Both halves
ship ready to install in
deploy/brain-box/:
brain-webhook.service (systemd, secrets from an EnvironmentFile) and
Caddyfile.webhook (TLS, exposes only /hook/* and /healthz).Caddyfile.webhook assumes
the box terminates its own TLS: DNS points at the box's public IP, Caddy
binds 80/443 and manages certificates. That's pattern A. If the box
instead sits behind edge-terminated TLS — a CDN in front, with an
outbound-only tunnel connector (Cloudflare Tunnel or similar) delivering
traffic to a loopback listener — that's pattern B, and the box keeps
zero inbound ports.| Inbound ports | Choose it when | |
A — Caddy terminates TLS (Caddyfile.webhook as shipped) | One (443) | The box has a public IP and DNS points straight at it. No third party in the intake path. |
| B — TLS ends at a CDN edge | Zero — the connector only dials out | You already front the box with a tunnel (say, for dashboards) and the hooks hostname can ride it. Trade: the CDN vendor becomes part of your intake path. |
Caddyfile.webhook — it would bind public
ports and manage certificates the edge already owns. Route the hostname
through what's already running instead:123# tunnel ingress (e.g. cloudflared config.yml) — before the catch-all rule - hostname: hooks-team.example.com service: http://127.0.0.1:8080 # the existing loopback listener
12345# inside the existing host-routed Caddy site block @hooks host hooks-team.example.com handle @hooks { reverse_proxy 127.0.0.1:8766 }
basic_auth (or any
browser-login gate) on /hook/* — sources authenticate themselves with
HMAC signatures or bearer tokens, and a password prompt only breaks
machine senders. And free universal certificates at most CDN edges cover
a single subdomain level, so hooks-team.example.com gets a certificate
where hooks.team.example.com needs a paid tier.brain ingest primitive the cron loop already audits, and
the failure drills below bound the worst case — but if your deployment
genuinely has no push sources, skip it and keep zero inbound.hermes-brain image with
deploy/agents-box/build-image.sh —
based on the official nousresearch/hermes-agent image, with brainkit
installed from the public repo. Use the script rather than a bare docker
build: it resolves the ref to a commit first, so the image is built from an
exact revision and tagged with it (see
below). One compose stanza per person:1234567891011121314151617services: agent-alice: image: hermes-brain:latest restart: unless-stopped command: gateway run environment: - BRAIN_PERSON=alice - BRAIN_GIT_REMOTE=brain-sync@brain-box:/srv/brain/compiled/alice # "brain-box" = the brain host on your chosen transport - TELEGRAM_BOT_TOKEN=${ALICE_BOT_TOKEN} - ANTHROPIC_API_KEY=${LLM_KEY} - BRAIN_EMBED_BASE_URL=${EMBED_URL} # optional: semantic search… - BRAIN_EMBED_API_KEY=${EMBED_API_KEY} # …all four, or containers - BRAIN_EMBED_MODEL=${EMBED_MODEL} # fall back to defaults - BRAIN_EMBED_DIM=${EMBED_DIM} volumes: - alice-state:/opt/data # hermes state: sessions, pairing, deploy key - alice-vault:/vault # her compiled vault clone
deploy/agents-box/
holds the Dockerfile, the compose file, the s6 scripts, and the
company-brain-profile/ staged into each container. brainkit itself is not
copied from the repo; it is installed from the public repo at build time.company-brain hermes profile
(SOUL.md, terminal.cwd: /vault, the brain MCP server and skills — with
hermes's built-in memory and its bundled llm-wiki skill disabled
(skills.disabled: [llm-wiki]), so the vault is the agent's only durable
memory and knowledge base and every "remember this" or distilled article
lands in a synced file) and
generates its own deploy key — the public half lands in docker logs,
the private half never leaves the container. A supervised sync loop then
clones $BRAIN_GIT_REMOTE, runs brain index,
and keeps syncing: pull → index → push every five minutes.skills/ is refreshed;
SOUL.md only if it is untouched since the image wrote it, so an edit
someone meant is preserved and the refusal logged; and config.yaml never,
because hermes rewrites it at runtime and regenerating it would discard live
settings including the model an operator chose. A SOUL.md that is missing
rather than edited is restored on the next boot, so a partial restore heals
itself.EMAIL_* variables to that person's stanza. Email is just another door
into the same container, so captures, attachments (vision / OCR), and
article distilling all land in the same synced vault. Two deployment
rules the hermes docs won't tell you: pin EMAIL_ALLOWED_USERS to the
one employee's real address — mailbox-to-person pinning does the same
job as the per-person bot token — and plan on real IMAP/SMTP mailboxes
with app passwords on a domain you control. Forward-only services
(Cloudflare Email Routing and kin) can't carry the channel, which must
fetch and send.1234environment: # …plus, for the email channel (full variable list in the hermes docs): - EMAIL_ADDRESS=alice@bots.example.com - EMAIL_ALLOWED_USERS=alice@example.com
deploy/agents-box/add-agent.sh <name> prints the compose stanza and this
exact checklist:org.yaml as a plain employee (no roles: — read-only on
Company by default; the admin role stays on a separate curation identity).
The next cycle compiles their vault. Set receive.denyCurrentBranch
updateInstead on the compiled repo so it accepts the agent's pushes.@BotFather → /newbot → copy the token (WhatsApp: link a
number via the hermes WhatsApp adapter).docker compose up -d.docker logs agent-<name> shows the container's deploy key — append one
authorized_keys line on the brain box (the brain-serve-repo forced
command locks the key to that one repo). The clone lands within 30s.ssh brain-box brain promotions approve <id> --master /srv/brain/master --approver admin
(admin is the curation identity from org.yaml); or review in the dashboard over an
SSH tunnel and approve there — the dropdown offers whoever may approve that
item. Promotions into a team's own space can also be cleared by that team's
lead from their own vault, so not everything in the queue needs you — see
Who approves.deploy/brain-box/backup-master.sh;
on the agents box,
deploy/agents-box/backup-agents.sh
zips each container's state nightly (hermes backup inside the container,
consistent even while the gateway runs) and
backup-agents-offsite.sh
ships the zips — plus the compose .env and live docker-compose.yml —
off-box encrypted. Vault volumes need no backup: they re-clone from the
brain box. The full picture, including why it's two buckets and how to
restore each failure, is in Backups & Restore.org.yaml, delete their authorized_keys
line, remove their container.brain --version is that question:1234567891011# brain box ssh brain-box sudo -u brain-sync brain --version # brain 0.2.0 (rev 69631f68e8c9) # any agent container ssh agents-box docker exec agent-alice brain --version # brain 0.2.0 (rev 69631f68e8c9) # the image itself, without starting anything ssh agents-box docker inspect --format \ '{{index .Config.Labels "org.opencontainers.image.revision"}}' hermes-brain:latest
0.2.0 on both boxes tells you nothing; rev 69631f68e8c9 on both tells you they match.uv tool install git+… on the brain box and the image's uv pip install git+… both write it to direct_url.json beside the package. Neither answer is stamped in by hand, so neither can drift from what is actually installed.hermes-brain:<short-sha> tag alongside :latest for a rollback target. build-image.sh resolves the ref to a commit before building, so the label names a revision rather than a branch.docker build still works; it installs whatever main points at, and the container can still say which commit that turned out to be.brain 0.1.2 with no revision. That is the honest answer: the information was never recorded. It resolves itself on the next rebuild.| Scenario | Outcome |
| Brain box down | Agents keep chatting and capturing on local clones; commits drain on recovery. Users notice nothing. |
| Agents box down | Chat goes quiet; zero data lost. docker compose up -d restores everything from volumes. |
| Agent volumes lost | Fresh container, hermes import <zip> --force with the person's latest backup, then docker restart (re-fixes key file modes) — sessions, pairing, and deploy key all return; the vault re-clones itself. |
| Agents box compromised | Rotate deploy keys, rebuild containers from the image — the master's history is intact and trustworthy. |
| Brain box lost | restic restore the offsite snapshot — vault, proxy/tunnel config, and the sync authorized_keys all return — reinstall brainkit, recompile. Agents reconnect without re-authorizing keys. See Backups & Restore. |
| Webhook secret leaked (if webhook intake is enabled) | Worst case is note spam into the Inboxes that source routes to, capped at the source's rate limit (60/minute by default) — a webhook source can never read anything or write outside an Inbox, and past the cap deliveries get 429s while the receiver logs a warning. Rotate the secret_env value and restart the receiver; the old secret is dead immediately. Every accepted delivery is its own audited Brain Ingest commit, so cleanup is git revert. |
| Webhook endpoint probed | Unauthenticated requests are refused before the payload is parsed (401/404), nothing is written, and oversized or compressed bodies are rejected outright. The receiver holds no read paths — there is no query to leak a note through. |