I text a phone number and a Raspberry Pi 4 answers with live numbers from my farm and homelab telemetry. It is read-only, answers exactly two people, and runs unattended. The Pi runs no model; the brain lives on a separate GPU box. I already had dashboards; I wanted to ask from the field with muddy gloves on, one line in, one number back. Signal is end-to-end encrypted, already on my phone, and has no official bot API, so the path is signal-cli as transport, a dedicated number as identity, and an agent runtime on signal-cli's local HTTP interface. What follows is the complete build, traps included.
The box
A Pi 4 with 8GB of RAM (4GB would do), the official PoE+ HAT for one-cable power and network, and a 1TB USB SSD; cutting PoE at the switch port is a remote hard reboot. The SSD was already on the shelf and I wanted the room, so that is what it boots from; an SD card is fine for most builds. Swap is the OS-default 2GB zram device (the rpi-swap package plus systemd-zram-generator on current Raspberry Pi OS Lite), so nothing swaps to the drive either way.
Flash Raspberry Pi OS Lite 64-bit (Debian 13, arm64) with Raspberry Pi Imager; Debian 13 also carries the Java runtime signal-cli needs. The default boot order (0xf41) tries SD first, then USB, so flash the SSD and leave the SD slot empty. Verify with findmnt -no SOURCE / (/dev/sda2) and vcgencmd get_throttled (0x0 = clean power).
The one bench trap worth carrying forward: a computer USB port under-volts a Pi 4 in a way that mimics a failing drive, booting and then hanging. Check the power before blaming storage. On the PoE HAT it stops happening.
A dedicated number, and signal-cli from scratch
The bot does not ride on my personal Signal account. Its identity is key material on an always-on box, so it gets its own free Google Voice number, registration lock, and profile; a compromise exposes nothing of my own account.
signal-cli is the unofficial client, not in apt; install the upstream JRE tarball (v0.14.6 here):
VER=0.14.6
curl -L -o /tmp/signal-cli.tgz \
https://github.com/AsamK/signal-cli/releases/download/v${VER}/signal-cli-${VER}.tar.gz
sudo tar xf /tmp/signal-cli.tgz -C /opt
sudo ln -sf /opt/signal-cli-${VER}/bin/signal-cli /usr/local/bin/signal-cliOn an ARM64 Pi it will not run yet.
The Java trap. signal-cli 0.14.6 ships class files compiled for Java 25. Debian 13's obvious pick is Java 21, under which it dies instantly with UnsupportedClassVersionError ... only recognizes class file versions up to 65.0. The fix is sudo apt-get install -y openjdk-25-jre-headless.
The native library trap. The bundled jar carries libsignal natives for Linux amd64, macOS, and Windows only; Signal publishes no aarch64 Linux build. Check the jar (sudo apt-get install -y unzip binutils first; a fresh Lite image ships neither unzip nor strings):
unzip -l /opt/signal-cli-0.14.6/lib/libsignal-client-0.96.3.jar | grep -E '\.so|\.dylib|\.dll'The community builds at exquo/signal-libs-build fill the gap; versions must match exactly, 0.96.3 on both here:
curl -L -o /tmp/libsignal_jni.tgz \
https://github.com/exquo/signal-libs-build/releases/download/libsignal_v0.96.3/libsignal_jni.so-v0.96.3-aarch64-unknown-linux-gnu.tar.gz
tar xzf /tmp/libsignal_jni.tgz -C /tmp
sudo mkdir -p /usr/lib/jni && sudo cp /tmp/libsignal_jni.so /usr/lib/jni/
strings /usr/lib/jni/libsignal_jni.so | grep 'Initializing libsignal' # must print 0.96.3No package owns /usr/lib/jni, so create it; it is already on OpenJDK's default java.library.path on Debian arm64, no JVM flags needed.

Registration. Signal wants a captcha token from signalcaptchas.org/registration/generate.html (copy the signalcaptcha:// link); tokens are single-use and expire in minutes. Google Voice often filters the verification SMS; --voice has Signal call and read the code aloud.
signal-cli -a +1XXXXXXXXXX register --captcha 'signalcaptcha://signal-hcaptcha....' --voice
signal-cli -a +1XXXXXXXXXX verify 123456
signal-cli -a +1XXXXXXXXXX setPin <registration-lock-pin>
signal-cli -a +1XXXXXXXXXX updateProfile --given-name "Agent"[403] Authorization failed at the captcha step means a stale session or spent token, not a VOIP block: rm -rf ~/.local/share/signal-cli/data, fresh captcha, run immediately.
Migration and the one-host rule. I registered on a laptop and moved the account. Everything under ~/.local/share/signal-cli/data, accounts.json included, is the account. Move all of it, run it on exactly one host (two instances desync the session and drop or duplicate messages), and treat it as key material: encrypted backups yes, git never.
tar czf /tmp/signal-data.tgz -C ~/.local/share/signal-cli data
scp /tmp/signal-data.tgz pi@agent:/tmp/
ssh pi@agent 'mkdir -p ~/.local/share/signal-cli && tar xzf /tmp/signal-data.tgz -C ~/.local/share/signal-cli && shred -u /tmp/signal-data.tgz'
shred -u /tmp/signal-data.tgzThe daemon. A systemd system unit (not a user unit) runs signal-cli -a +1XXXXXXXXXX daemon --http 127.0.0.1:8686 as the unprivileged login user, with After= and Wants=network-online.target, Restart=on-failure, RestartSec=5. The daemon has no auth, hence loopback only. Health-check it: curl -s -X POST -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","method":"version","id":1}' http://127.0.0.1:8686/api/v1/rpc returns the version.
The runtime, built from source
The agent runtime is ZeroClaw v0.8.3, a single 32 MiB Rust binary with a native Signal channel. No prebuilt release includes that channel: releases build with --no-default-features against a feature list that omits channel-signal, so the config schema accepts a Signal channel the shipped binary cannot run. Hence the source build:
sudo apt-get install -y git build-essential pkg-config libssl-dev cmake protobuf-compiler clang curl
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
. "$HOME/.cargo/env" # rustup does not touch the current shell's PATH
git clone --branch v0.8.3 https://github.com/zeroclaw-labs/zeroclaw ~/.zeroclaw/src # pin it; the traps here are as-of v0.8.3
cd ~/.zeroclaw/src
CARGO_PROFILE_RELEASE_LTO=thin cargo install --path . --locked --force --features channel-signal,landlockThe LTO override matters: the repo pins fat LTO with one codegen unit, which can die at link time on low-RAM ARM. This Pi finished in 32m 25s, 377 crates under rustc 1.97.1, leaving 1.2 GB in target/. For rebuilds, cargo install cross, then cross build --release --target aarch64-unknown-linux-gnu --features channel-signal,landlock on a faster host (cross needs Docker and a first-run image pull); dropping --features silently loses the channel.
Verification is its own trap: zeroclaw channel add --help prints a static list that never includes signal, even after a successful build. Trust the journal line Channels: signal.default or zeroclaw doctor showing channel:signal.default fresh.
Persistence is a hand-written system unit, not zeroclaw service install, which registers a user service that can stop at logout with lingering disabled (the Raspberry Pi OS default). The unit runs as the unprivileged user with Requires= and After= on signal-cli.service, and ExecStart=/home/agent/.cargo/bin/zeroclaw daemon; cargo installs off the non-interactive PATH.
The daemon also serves a web dashboard, useful for reading sessions and approvals that are awkward to inspect from a chat thread. It binds loopback by default, and host is what puts it on the LAN:
[gateway]
host = "0.0.0.0" # this is what exposes it
port = 42617
allow_public_bind = true # silences a warning, does not gate the bindallow_public_bind reads like a permission but does not act as one. The gateway warns when it binds a public interface with no tunnel configured and the flag unset, then binds anyway; the flag only suppresses the warning. Access is gated by a pairing token instead, stored encrypted in the config, and the API returns 401 without one. It is plain HTTP with no TLS, so it belongs on a LAN you trust and behind nothing that forwards it outward. Signal stays the day-to-day interface; the dashboard is for when something needs explaining.
The Pi runs no model
The Pi holds the identity, channel, and security posture; the GPU box is just an OpenAI-compatible endpoint at http://<llm-host>:8000/v1, allowed to be off. The seat currently holds GLM-4.7-Flash at 4-bit under llama.cpp; why that model won it is its own post.

llama.cpp does not validate the configured model name (it serves the loaded GGUF and reports its file path as the id); verify with GET /v1/models. No fallback provider, on purpose: during the build, with the GPU box off, the runtime made three connection attempts, logged All model_providers/models failed, and the agent still sent a Signal reply within about half a second.
Read-only, enforced in layers
Version one answers questions and does nothing else: an LLM with tools will eventually be wrong, and the cheap defense is making wrong harmless.
The readonly autonomy level blocks the read-only HTTP tool. It gates the whole http_request tool before the HTTP method is parsed; every GET fails with Action blocked: autonomy is read-only. The working recipe is level = "supervised", read-only in practice through layers: on a chat channel any tool not in auto_approve is auto-denied, not queued; excluded_tools strips every write and escape verb; and the surviving outbound tool reaches exactly one host (http_request is not verb-limited once approved; the telemetry queries are GETs against http://<metrics-host>:9090/api/v1/query). The pin is host-scoped, not port-scoped: every port on the metrics box is in reach, Grafana's HTTP API included, so read-only there also leans on each service's own auth.
Private egress is denied by default (SSRF hardening): until allow_private_hosts = true with the host in allowed_private_hosts, the tool fails with Blocked local/private host. Separately, replace the default allowed_domains = ["*"] with the same host so public egress is pinned too.
On top of raw Prometheus sit 15 read-only Grafana tools from mcp-grafana v0.17.2 (the linux/arm64 release tarball from github.com/grafana/mcp-grafana, binary at /usr/local/bin/mcp-grafana), on loopback. Mint the token in Grafana under Administration, Service accounts, Viewer role, then verify it: the effective-permissions list should come back with zero write, create, or delete entries, and a real POST /api/folders should return 403. The env file is root-owned 0600 at /etc/mcp-grafana.env and carries two lines, GRAFANA_URL=http://<metrics-host>:3000 and GRAFANA_SERVICE_ACCOUNT_TOKEN (not the deprecated GRAFANA_API_KEY); without GRAFANA_URL the server looks for Grafana on localhost. It is read via EnvironmentFile=:
ExecStart=/usr/local/bin/mcp-grafana -t sse -address 127.0.0.1:8000 \
-disable-write -enabled-tools prometheus,dashboard,datasource,searchThe trim spares a small model the schema flood; the payoff, grafana__get_dashboard_panel_queries, reuses a dashboard's exact PromQL. The full wiring in ~/.zeroclaw/config.toml, traps annotated:
[providers.models.vllm.glm]
uri = "http://<llm-host>:8000/v1"
model = "glm-4.7-flash"
api_key = "<plaintext-key>" # zeroclaw stores it encrypted (enc2:)
[channels.signal.default]
account = "+1XXXXXXXXXX"
http_url = "http://127.0.0.1:8686"
dm_only = true
[peer_groups.owner]
channel = "signal.default"
agents = ["agent"]
external_peers = ["+1YYYYYYYYYY", "<aci-uuid>"] # E.164 AND the ACI UUID
[http_request]
allow_private_hosts = true
allowed_private_hosts = ["<metrics-host>"]
allowed_domains = ["<metrics-host>"] # replaces the default "*"
[risk_profiles.readonly]
level = "supervised" # NOT "readonly": that blocks http_request entirely
auto_approve = ["http_request", "file_read", "memory_recall", "calculator",
"grafana__query_prometheus", "..."] # all 15 grafana tools, by exact name
excluded_tools = ["shell", "file_write", "web_fetch", "cron_add",
"delegate", "spawn_subagent", "..."] # 76 entries live
[mcp]
enabled = true
deferred_loading = false
[[mcp.servers]]
name = "grafana"
transport = "sse" # the field is transport, not type
url = "http://127.0.0.1:8000/sse"
[mcp_bundles.grafana]
servers = ["grafana"]
[agents.agent]
model_provider = "vllm.glm"
risk_profile = "readonly"
channels = ["signal.default"]
skill_bundles = ["farm"] # the PromQL skill
mcp_bundles = ["grafana"] # a bundle is a grant; omission is not a grantThe failure modes are all silent: a missing bundle grant parses cleanly and loads nothing, a type key instead of transport is ignored (the server defaults to stdio), and grafana__ matches nothing; the approval gate is exact string match, and "" would approve the dangerous tools too. The 15 names for that gate, each with the grafana__ prefix: check_datasources_health, get_dashboard_by_uid, get_dashboard_panel_queries, get_dashboard_property, get_dashboard_summary, get_datasource, list_datasources, list_prometheus_label_names, list_prometheus_label_values, list_prometheus_metric_metadata, list_prometheus_metric_names, query_prometheus, query_prometheus_histogram, search_dashboards, search_folders. To build the exclusion list, enumerate the binary's full inventory (zeroclaw agent -a agent -m 'list your tools') and exclude everything not in auto_approve; re-run after any rebuild, since a new feature flag can add tools. Three live names (weather, tool_search, content_search) also sit in excluded_tools; treat excluded_tools as authoritative and keep the lists disjoint.
The last layer is the prompt. A 70-line skill file (~/.zeroclaw/shared/skills/farm/farm-telemetry/SKILL.md, granted by skill_bundles) ships pre-built PromQL and bakes in last_over_time(<metric>[6h]) for LoRaWAN sensors: they uplink minutes apart, Prometheus marks the series stale, an instant query returns empty, and empty invites a guess. The skill defines empty as "no recent uplink." Workspace files in ~/.zeroclaw/agents/<agent>/workspace/ (SOUL.md persona, AGENTS.md doctrine) inject into the system prompt; AGENTS.md orders the model never to type a metric name from memory. For metric discovery use /api/v1/label/__name__/values; the MCP metric-name tool defaults to 10 alphabetical results.
The allow-list is the security boundary
signal-cli delivers every inbound message; there is no message-request gate. Whoever can message an LLM holding tools is inside the trust boundary, because prompt injection rides in on ordinary text. The boundary is the sender allow-list, enforced before any text reaches the model, failing closed: empty denies everyone, "*" allows anyone, matching is exact and case-sensitive. The live list is two people, the union of external_peers across the peer groups.
A peer with phone-number sharing off arrives as an ACI UUID, a +1-only list fails the match, and the drop writes no log and sends no reply; list both identities per peer. The UUID comes from the daemon: after the peer messages the bot once, the same JSON-RPC call as the health check with "method":"listContacts" returns each contact with a uuid field. Ask the running daemon; a second signal-cli process against the live account violates the one-host rule. The same silence covers strangers, which is what you want, but it leaves no audit trail of denied senders and no record of a misconfigured legitimate one until they complain in person.
Limitations
The sandbox status line lies on this kernel. zeroclaw security status reports an active landlock sandbox, but the stock Raspberry Pi OS kernel ships CONFIG_SECURITY_LANDLOCK unset, so nothing is enforced; the landlock crate's best-effort default makes the probe succeed anyway. Check grep -i landlock /boot/config-$(uname -r) and cat /sys/kernel/security/lsm. Containment is application-layer, making read-only a posture, not a kernel guarantee: one config edit from not being read-only, holding only while the excluded list stays complete and egress stays pinned. Re-audit after any config change.
The gateway above is the only non-loopback listener the stack adds; sshd is the box's other open port. No dedicated service users; two of three units have no hardening directives. The agent is only as available as the GPU box; a cloud fallback would undercut the point.
Where it lands
The shape transfers: a chat channel people already carry, a dedicated bot identity, an allow-list that fails closed, tools that cannot write pointed at one host, and a brain on another machine that is allowed to be off. It answers when I ask and stays silent to everyone else.
Comments
// Comments are reviewed before appearing. No spam. No noise.