Aromba — Architecture
Draft. This is a starting sketch, not the final design. The real architecture will be worked out in Claude Code with scalability as a hard requirement: many servers per user, many users per relay, skills and dashboard added without engine rewrites. Treat every choice below as a proposal to challenge, except the security boundaries in
05-security.md, which stay.
Three parts. Two of them are ours to run; one lives on the user’s server.
Split principle (fixed, even in the draft). The daemon must stay light (00-overview.md principle 8), so not everything can live in it. The line: anything that touches files or secrets runs on the user’s server — chores, log filtering, notebook, honeypots, the assistant’s tool calls. Anything heavy that works only on data the server already sends may run on the relay, opt-in: scene and map rendering, long-range history aggregation, share images, the hub. Voice transcription runs on the server when it can, otherwise through the AI provider by the user’s choice. When in doubt, the server keeps it.
user's server aro.mba (ours) Telegram
┌───────────────────┐ HTTPS/WS ┌───────────────┐ Bot API ┌────────┐
│ aromba daemon │ ──────────► │ relay │ ──────────► │ @Aromba│
│ scheduler │ ◄────────── │ pairing │ ◄────────── │ Bot │
│ chores │ │ message queue│ └────────┘
│ watchers │ │ (phase 4: │
│ assistant │ ── HTTPS ─► │ dashboard) │
│ journal │ AI provider└───────────────┘
└───────────────────┘
The daemon does all the work. The relay only moves messages between the daemon and Telegram and, later, stores what the dashboard shows. The AI provider is called directly from the daemon; the relay never sees prompts or keys.
1. Daemon (aromba)
Two static Go binaries from one module: aromba (the Worker and the CLI) and aromba-warden (the Warden, 05-security.md section 2). Reasons for Go: single-file install with curl | sh, no runtime to install on a bare VPS, easy cross-compile for amd64/arm64 and later macOS, good process control and timeouts. The Warden links no SQLite, no WebSocket and no AI client, so it stays small and reviewable.
Packages, one job each:
| Binary | Package | Job |
|---|---|---|
| Worker | config | config.toml, secrets.toml, relay.toml, network.toml; mtime-poll reload |
| Worker | state | SQLite at /var/lib/aromba/state.db: chore states, approvals, alerts, cache, health ring |
| Worker | journal | Append-only log; builds receipts and aromba log |
| Worker | relay | Outbound WebSocket to aro.mba; reconnects; local queue; delivers messages and taps to bot |
| Worker | bot, voice | Commands, buttons, settings; every outbound line formatted in one place with the style presets |
| Worker | scan, health, inventory | First scan and /scan; /proc once a minute; server card and weekly inventory |
| Worker | engine, scheduler, runner | Chore loading and rules; quiet window, ticks, events; builds the run request the Warden executes |
| Worker | quarantine | Index, restore, 30-day purge |
| Worker | skills | Discovers /opt/aromba/skills/*, validates manifests, loads chores, watchers, tools and prompt text |
| Worker | assistant | Prompt assembly, tool loop, provider adapters, cache, notebook, budget |
| Worker | update, cli | Signed updates with rollback; `aromba status |
| Warden | server | The two sockets; peer uid checked on every connection |
| Warden | policy, redact | Allow/ask/deny lists, path rules, approvals; redaction of every result |
| Warden | cage, exec | bubblewrap from touches and fences; execution, timeouts, damage cap |
| Warden | integrity, ladder | Signature and policy checks; the eight signals and four steps |
| Warden | fence, honeypot, snapshot | Auto-fence, decoys, snapshots for undo |
| Shared | proto, chore, sign, redact, version | Wire formats, header parser, signature verification, the redaction corpus |
Chores are bash and run as subprocesses inside the Warden’s cage; the daemon never embeds shell logic. Assistant tools are Go functions that call into the Warden’s policy first.
State: the Worker’s SQLite holds everything the Worker may change. The Warden keeps its own small state as root-owned files under /etc/aromba/warden/ — no SQLite in the Warden. Journal is a plain append-only file, rotated monthly.
2. Relay (relay.aro.mba)
Small service, Go, on a Vultr VPS in Frankfurt, Postgres behind it. Jobs:
- Registration and pairing: a daemon generates its own
server_idandrelay_tokenat first start and posts them with itsboot_idtoPOST /v1/servers— open, rate-limited per IP — and gets a 6-char pairing token back./start <token>from Telegram binds the chat:server_id ↔ chat_id ↔ relay_token. Unpaired servers are erased after 24 hours. A registration that repeats a knownserver_idwith a differentboot_idnever overwrites the record; it goes to the duplicate check (14-failure.md). - Messages: receives Telegram updates via webhook, forwards to the right server over its WebSocket; receives messages from servers and sends them to Telegram. In-transit only, deleted on delivery, 24 h max if a server is offline.
- Presence: knows if a server is online; answers the user if a message arrives while it isn’t.
- Phase 4: stores receipts and health numbers for the dashboard, per user.
- Phase 2, what we run for the fleet (
05-security.md§11,11-money.md§5–§6): the Warden’s own channel — its ladder alerts to the chat, and the signed freeze signal it fetches by long-poll, checked against the freeze public key before the relay serves it; opt-in incident reports and nightly totals, under a random install id and never beside a server; the staged rollout’s answer to “has this release reached me yet”, halted by a rise in reports and ours to let go on; the founder seat page’s sign-in and its payment watcher, which reads one chain through a JSON-RPC endpoint and holds an xpub, never a key that can spend. On a self-hosted relay none of this is configured: no admin token means no freeze and no stages (every release may go), no xpub means the founders paths do not exist.
The relay never runs commands, never sees AI traffic. It is the only part that holds the Telegram bot token. A compromised relay can read and inject chat messages, which is why red approvals show the exact command and why the terminal stays the root of trust.
Stack is deliberately boring: Go + Postgres + Caddy for TLS — one proxy, on the relay’s own machine: the relay’s limits by address trust the address that one proxy reports and no other, so a CDN in front of it would need the relay changed first. No queue service, no Kubernetes. One VPS handles thousands of idle daemons; each holds one WebSocket and sends a few KB a day.
3. Website and dashboard (aro.mba)
Static site (landing, docs, changelog, the founder seat page), served as files from our own server behind its reverse proxy. Dashboard in phase 4: auth through the bot itself (a code on the page, confirmed in the chat), reads from the relay’s Postgres through a small API. Read-only in its first release. Details in 08-dashboard.md.
4. On the user’s server
/usr/local/bin/aromba link to /opt/aromba/current/aromba (same for aromba-warden)
/opt/aromba/releases/<version>/ one folder per installed release: both binaries and its manifest
/opt/aromba/current link to the release that runs; swapped atomically by the Warden
/opt/aromba/previous link to the one before it, kept for a rollback
/opt/aromba/release.json which is which, and whether current has passed its probation
/opt/aromba/rollback.json left by a rollback; the Worker reports it once
/etc/aromba/ root-owned, mode 0755 (D62a): a compromised Worker cannot rename anything at this level, including the Warden's own folder below
/etc/aromba/worker/config.toml settings (bot edits this)
/etc/aromba/worker/secrets.toml AI key — mode 600, wiped by the Warden at the ladder's Cut
/etc/aromba/worker/relay.toml the relay identity: server id and token — mode 600, so the chat outlives that wipe
/etc/aromba/worker/network.toml allowed outbound hosts
/etc/aromba/worker/intake.toml the products on this machine that hand Aromba work, and their keys — mode 600, hidden from every cage (15-instruments.md)
/etc/aromba/warden/policy.toml fixed command policy, root-owned, signed with the release
/etc/systemd/system/aromba.service Worker, user aromba
/etc/systemd/system/aromba-warden.service Warden, root
/run/aromba/warden.sock Worker → Warden requests; answers uid aromba only
/run/aromba/control.sock terminal → Warden: panic, resume; answers root only
/run/aromba/worker/worker.sock terminal → daemon: scan, pair, live status; root or aromba
/opt/aromba/chores/core/ shipped chores
/opt/aromba/chores/community/ added with `aromba chore add`
/opt/aromba/chores/mine/ user's and assistant's, never overwritten
/opt/aromba/skills/<name>/ installed skills
/var/lib/aromba/ root-owned, mode 0755 (D62a), same reasoning as /etc/aromba
/var/lib/aromba/worker/state.db SQLite
/var/lib/aromba/worker/instruments/<slug>/ what was built for a product: instrument.json and template.html (15-instruments.md)
/var/lib/aromba/journal/ append-only log, the Warden's, root-owned
/var/lib/aromba/worker/updates/<version>/ a downloaded release waiting for the Warden's verdict
/var/lib/aromba/quarantine/<date>/
/var/lib/aromba/notebook.md assistant memory
/var/lib/aromba/models/ whisper model if voice is on
5. Daemon lifecycle
start → verify binary signature → load config → load chores and skills
→ connect relay (retry forever, backoff to 5 min)
→ if not paired: request token, print link, wait
→ run first scan once, send report
→ loop: tick scheduler · handle events · handle bot messages · run assistant turns
panic → stop scheduler, kill runners, wipe key, relay stays up in restricted mode
resume (terminal only) → reload config, back to loop
Offline behavior: chores keep running from local schedule; messages and receipts queue locally and flush when the relay is back. A server that was off during the quiet window runs its night chores on the next quiet window, not on boot.
6. Message flow, one round trip
- Watcher writes
alert=disk 91%→ engine emitsdisk-85event and an alert message. botformats it,relaysends it:{server_id, text, buttons:[{id:"look"},{id:"ignore"}]}.- Relay posts to Telegram with inline keyboard, callback data =
server_id:msg_id:button_id. - User taps. Telegram → relay webhook → relay forwards
{msg_id, button_id, from_chat}to the server. botchecksfrom_chat == paired chat,policychecks approval validity, then acts.- Result is journaled, the message is edited via the same path.
Every message carries a nonce so replays are dropped.
7. Skills
A skill is a folder:
skills/<name>/
skill.toml name, version, description, kind (chores|capability|integration|knowledge),
weight (disk_mb, ram_mb), min aromba version, needs,
permissions: paths, root commands, network hosts
chores/ same chore format
watchers/ same, verb: watch
tools/ optional assistant tools: a script plus a JSON schema, caged like a chore
prompt.md optional text added to the assistant's context while enabled
receipt.md optional template for its own receipt section
aromba skill add <name|url> installs from the catalog at aro.mba/skills or a git URL; the bot and dashboard do the same with a tap. The install shows weight and every permission; the user confirms each; nothing in a skill can widen its permissions later. Official skills are signed with our release key; community skills run red until reviewed and promoted. The base install carries only the system skill; the first scan suggests the rest (Docker found → docker skill, Postgres → postgres, and so on). Heavy skills (voice) show their weight against the user’s skills budget.
8. Repository layout
aromba/
cmd/aromba/ main
internal/ packages from section 1
chores/core/ shipped chores
skills/system the only built-in skill
skills/catalog/ official skills, published to aro.mba/skills
relay/ relay service
web/ aro.mba site (phase 1: landing + install script only)
docs/ these documents
scripts/install.sh what `curl | sh` fetches
.github/workflows/ test, build, sign, release
SECURITY.md, CLAUDE.md, README.md
One repo for daemon, relay and site in phase 1–3; split later if contributors need it.
9. Testing
- Chore tests: every core chore runs in dry-run and real mode inside a throwaway Docker container per supported OS in CI. Real mode asserts the receipt line and that nothing outside
toucheschanged (filesystem diff). - Policy tests: a table of proposed commands with expected allow/ask/deny; deny list must never regress.
- Redaction tests: a corpus of fake secrets that must never appear in tool output.
- End-to-end: a fake relay and a fake Telegram in CI drive pairing → scan → approval → receipt.
10. Decisions log
| Decision | Why | Revisit when |
|---|---|---|
| Go static binary | one-command install on bare VPS | never |
| Chores in bash | anyone can read and write one; assistant can too | Windows support |
| Relay instead of bot token on each server | one leaked server can’t impersonate the bot | never |
| WebSocket from server, no inbound ports | works behind any firewall | never |
| SQLite on server | zero setup, one file | never |
| Relay in Go + Postgres on one VPS | boring, cheap | > 20k daemons |
| No web UI before phase 4 | Telegram is enough to prove value | phase 4 |
| Base install minimal, everything else a skill | stays under the weight budget; users add only what their server needs | never |
| Warden as root, Worker with no sudo | redaction and the cage both need one privileged place | never |
| Two binaries | the Warden stays tiny and reviewable | never |
| Two sockets, peer uid checked by the kernel | terminal-only commands are enforced, not promised | never |
| Pure-Go SQLite, no cgo | static binary, plain cross-compile | if size becomes a problem |
| Three signing keys | a leaked release key can neither freeze nor rotate | never |
| Warden long-polls the relay for the freeze signal | a compromised Worker can’t block a freeze; one idle HTTPS request, not a second WebSocket | never |
| Registration is open, rate-limited, erased after 24 h | a daemon must exist before it can be paired | abuse |