`!ai` sends the question to a language model. `!archy` never does: it reads the same status caches the /bitcoin-status and /electrs-status endpoints serve, so answers are deterministic, cost no tokens, and stay available when the assistant is switched off. Sub-commands: status (default), btc, electrs, version; anything else returns a usage hint. Replies are single-frame terse to respect airtime. Reuses the assistant's trust gate (is_sender_allowed) and reply routing unchanged — blocked, allowlist, trusted_only and federation-Trusted all behave identically. The one deliberate asymmetry is that !archy does not require assistant_enabled, since it never calls a model. Wired into both entry paths: plain radio text (decode.rs) and typed 1:1 chat (dispatch.rs). A command hooked into only one of them would work from a phone but not the UI, or vice versa. Adds docs/COMMANDS.md — the user-facing surface for mesh, voice and HTTP, which had no reference until now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.3 KiB
Talking to your node
Every way to ask an Archipelago node a question: over the LoRa mesh, by voice, and over HTTP.
Two rules worth internalising before the tables:
!aiasks a language model.!archynever does.!archyreads the same status caches the HTTP endpoints serve, so its answers are deterministic, cost nothing, and keep working with the assistant switched off.- Mesh command prefixes are exact strings (case-insensitive). Voice phrases are not — they are matched by Home Assistant's intent parser, so the examples below are representative, not literal.
1. Mesh commands
Sent as ordinary text over the mesh — either as plain channel/DM text from a stock meshcore or Meshtastic client, or typed into a 1:1 chat in the Archipelago UI.
!archy — node status, no AI
| Command | Aliases | Answers with |
|---|---|---|
!archy |
!archy status |
OS version, chain tip, peer count, electrum progress |
!archy btc |
bitcoin, node, sync |
Sync state, block height, peer count |
!archy electrs |
electrum |
Electrum index progress |
!archy version |
ver |
Archipelago OS version |
| anything else | — | A one-line usage hint |
Examples of what comes back:
!archy → Archipelago OS v1.7.99-alpha: BTC synced 957295 (12p), electrum 86%.
!archy btc → BTC: synced, block 957295, 12 peers.
!archy electrs → Electrum: syncing 86.2% (824785/957295).
!archy version → Archipelago OS v1.7.99-alpha
!archy wat → archy: !archy [status|btc|electrs|version]. Node status, no AI.
!archyfoo is not a command — the prefix must be followed by whitespace or end-of-message.
!ai / !ask — language model
!ai what is the halving schedule
!ask how do I open a lightning channel
Requires the assistant to be enabled (assistant_enabled in mesh-config.json). Backend is ollama (default, qwen2.5-coder) or claude (claude-haiku-4-5-20251001), set by assistant_backend. The model is told to reply in at most two short sentences, because airtime is scarce.
Who is allowed to ask
Both commands share one gate — is_sender_allowed() in mesh/listener/assist.rs. Evaluated in order:
- Blocked contact → always denied.
- On
assistant_allowed_contacts→ allowed, even without a signature. This is the deliberate opt-in for keyless phone clients. assistant_trusted_only == false→ anyone on the mesh may ask.assistant_trusted_only == true→ the asker must be authenticated and carry federationTrustedstatus.
"Authenticated" means the message carried an Ed25519 signature that verified against the sender's known identity key, or it arrived over the federation (Tor) transport, which verifies upstream. Bare plain-text radio messages are never authenticated — so on a trusted_only node, a stock meshcore client can only get an answer by being on the allowlist.
Denials are silent on the wire (no airtime is spent saying "no"); the asker is recorded so an operator can allow them from the UI.
The one asymmetry: !ai additionally requires assistant_enabled; !archy does not, because it never calls a model. Turning the LLM off should not take node status with it.
Where the answer goes
| You asked from | Reply arrives as |
|---|---|
| Plain radio text, your pubkey is known | A private unicast DM (not the public channel) |
| Plain radio text, pubkey unresolvable | A broadcast on channel 0 |
| 1:1 chat in the Archipelago UI | A chat bubble in the same thread |
The AssistQuery widget |
Ordered, reassembled AssistResponse chunks |
Size caps: 480 characters per answer overall, 200 for plain-text channel/DM replies, and a hard 160-byte LoRa frame limit underneath both.
2. Voice
A PineVoice satellite speaker, wake word "Hey Jarvis" (or press the centre button). Speech-to-text is Whisper, text-to-speech is Piper — both run locally on the node. Nothing leaves the box.
Phrases are matched by intent, so wording is flexible. These are examples, not exact strings:
| Ask something like | You hear |
|---|---|
| "What is Archipelago OS?" · "What is this node running?" | A one-sentence description plus the running version |
| "What version am I running?" | Version and uptime |
| "Is my node synced?" · "How is my Bitcoin node doing?" · "Bitcoin node status" | Sync state, block height, peer count |
| "What's the current block height?" · "How many blocks do we have?" | The chain tip |
| "Is the electrum server synced?" · "Electrum status" | Index progress |
Optional and alternative words are part of the templates, so "how is the node doing" and "how is my Bitcoin node" both land on the same intent.
How voice is wired
The speaker is a Wyoming satellite. Home Assistant runs it through an Assist pipeline: wake word on-device → audio streamed to Whisper → intent matched → Piper speaks the answer.
| Piece | Location (on the node) |
|---|---|
| Whisper + Piper services | ~/.config/containers/systemd/wyoming-{whisper,piper}.container |
| Wyoming entries, Assist pipeline | home-assistant/.storage/{core.config_entries,assist_pipeline.pipelines} |
| Sensors + spoken answers | home-assistant/configuration.yaml (rest: and intent_script:) |
| Phrasings | home-assistant/custom_sentences/en/archipelago.yaml |
Home Assistant reaches the node's own HTTP API at host.containers.internal — not the node's LAN IP, which under rootless podman's pasta networking resolves back to the container itself.
Adding a phrase means editing custom_sentences/en/archipelago.yaml; adding an answer means adding an intent_script entry (and a rest: sensor if it needs new data).
3. HTTP
Served by nginx on port 80, proxying the backend on 127.0.0.1:5678.
No authentication required (5-second cache):
| Endpoint | Returns |
|---|---|
GET /health |
Status, uptime, version, services |
GET /bitcoin-status |
getblockchaininfo + getnetworkinfo + getindexinfo |
GET /electrs-status |
Index height, progress, onion address |
curl -s http://<node>/bitcoin-status | jq '.blockchain_info.blocks'
Session required — everything else goes through JSON-RPC at POST /rpc/v1:
curl -s http://<node>/rpc/v1 -H 'Content-Type: application/json' \
-d '{"method":"auth.login","params":{"password":"…"}}' -c jar.txt
curl -s http://<node>/rpc/v1 -b jar.txt -H 'Content-Type: application/json' \
-d '{"method":"system.stats","params":{}}'
Login returns a session cookie. Read-only methods (system.stats, system.get-metrics, bitcoin.getinfo, monitoring.current, bitcoin.relay-status, tor.status) are CSRF-exempt, so the cookie alone is enough; state-changing calls also need the X-CSRF-Token header. If TOTP is enabled, follow the login with auth.login.totp.
Adding a command
- New
!archysub-command — add a variant toNodeCmdand a match arm inrun_node_cmd, both inmesh/listener/node_cmd.rs. Keep answers under 200 characters so a stock client sees the whole thing in one frame. - New mesh prefix — add a
strip_*_triggeralongsidestrip_archy_trigger, then hook it indecode.rs(plain radio text) anddispatch.rs(typed 1:1 chat). Both paths must be wired or the command only works from one of them. - New voice phrase or answer — see the voice table above.