Hand this prompt to any Hermes agent and it installs, links, and verifies the Signal gateway end-to-end — including the ARM64/aarch64 native-library fix.
+614…) and the machine paths are placeholders. Every install uses
its own phone number and home directory — replace +<NUMBER> and
/opt/signal-cli-<VERSION> with the real values on your system before running any
command. No bot tokens or API keys are required to set up Signal.Signal is the most privacy-focused mainstream messenger — end-to-end encrypted by default, open-source, minimal metadata. Hermes connects to Signal through the signal-cli daemon running in HTTP mode. The Hermes Signal adapter streams inbound messages in real-time via SSE and sends outbound messages via JSON-RPC. Crucially, the Hermes adapter needs no new Python packages — it only needs signal-cli running externally.
This guide is a portable system prompt: give it to any Hermes agent on any Linux server and it will install, link, configure, and verify everything itself. The only value that requires a human is the phone number, captured when the operator scans a QR to link the account.
This single step drives the whole install. signal-cli bundles native cryptography libraries (libsignal-client) only for x86_64 Linux, Windows, and macOS. On any other CPU (ARM64/aarch64, armv7, riscv64, …) you must supply a matching native library yourself or signal-cli fails with:
WARN Manager - Failed to call libsignal-client: no signal_jni in java.library.path
Missing required native library dependency: libsignal-client
uname -m
# x86_64 -> straightforward install (skip Step 3)
# aarch64 / arm64 / armv7 / riscv64 -> you need Step 3
signal-cli is a Java application. Recent releases (v0.14.8+) are compiled for Java class file
version 69.0, which means Java 25. Java 17 or 21 is not enough — you will
hit UnsupportedClassVersionError. On Debian/Ubuntu:
sudo apt-get install -y openjdk-25-jre-headless
java -version # expect "openjdk version 25.x"
If apt only offers an older JDK, install a 25 build from an official OpenJDK PPA or via SDKMAN, or choose a signal-cli release that matches an older class-file version.
VERSION=$(curl -Ls -o /dev/null -w %{url_effective} \
https://github.com/AsamK/signal-cli/releases/latest | sed 's/.*\/v//')
curl -L -O "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}.tar.gz"
sudo tar xf "signal-cli-${VERSION}.tar.gz" -C /opt
sudo ln -sf "/opt/signal-cli-${VERSION}/bin/signal-cli" /usr/local/bin/signal-cli
signal-cli --version # confirm it prints a version
A UnsupportedClassVersionError ... class file version 69.0 means Java is too old —
return to Step 1.
Skip this step entirely on x86_64. On aarch64, signal-cli does not ship the native lib. First, find the libsignal-client version signal-cli needs:
ls /opt/signal-cli-${VERSION}/lib/ | grep libsignal-client
# e.g. libsignal-client-0.102.1.jar -> serialized token "so01021"
Download a prebuilt aarch64 lib matching that version from a community mirror. The serialized
so<NNNN> token is the version digits concatenated (e.g. 0.102.1 →
so01021):
libsignal_jni_<soNNNN>_...libsignal_jni_so01021_ubuntu2004_arm64.gz (needs glibc ≥ 2.30)curl -sL -o /tmp/libsignal_arm64.gz \
"https://media.projektzentrisch.de/temp/signal-cli/libsignal_jni_<soNNNN>_ubuntu2004_arm64.gz"
gunzip -f /tmp/libsignal_arm64.gz
sudo mkdir -p /opt/signal-cli-${VERSION}/native
sudo cp /tmp/libsignal_arm64 /opt/signal-cli-${VERSION}/native/libsignal_jni.so
file /opt/signal-cli-${VERSION}/native/libsignal_jni.so # MUST read "ELF ... ARM aarch64"
Make the JVM use it. The signal-cli launcher forwards a SIGNAL_CLI_OPTS env var to
every run — export it in your shell profile and prefix every signal-cli command:
export SIGNAL_CLI_OPTS="-Djava.library.path=/opt/signal-cli-${VERSION}/native"
# persist: append that export line to ~/.bashrc
SIGNAL_CLI_OPTS=... signal-cli --version
SIGNAL_CLI_OPTS=... timeout 15 signal-cli link -n "Test"
# both must now work; link prints a "sgnl://linkdevice?uuid=..." URI
This is the only interactive step. signal-cli runs as a linked secondary device of the operator's phone (like WhatsApp Web). The operator scans a QR code with Signal:
sudo apt-get install -y qrencode
# start the link in a background process so it stays alive while they scan,
signal-cli link -n "HermesAgent"
# read the resulting "sgnl://linkdevice?uuid=...&pub_key=..." URI and render it:
qrencode -t ANSIUTF8 -o - "<the-sgnl-link-uri>"
Present the QR and tell the operator: Open Signal on phone → Settings → Linked
Devices → Link New Device → scan. The process prints
Associated with: +614... when the scan succeeds. The link expires in ~1–2 minutes;
re-run it if it times out. Capture the E.164 phone number — it becomes
SIGNAL_ACCOUNT.
signal-cli listAccounts # confirm the Number; may block if the daemon holds the lock
+614****6080 in all output — link/listAccounts
logs, accounts.json, even the SQLite account.db — as a privacy
feature. You cannot recover the full number from disk. Ask the operator for their
E.164 (+614XXXXXXXX) to set SIGNAL_ACCOUNT /
SIGNAL_ALLOWED_USERS / SIGNAL_HOME_CHANNEL and to target the send test.signal-cli link in a
background subprocess, immediately read the printed sgnl://linkdevice?... URI,
qrencode it to a PNG, and push the image to the operator's Telegram via
sendPhoto (using TELEGRAM_BOT_TOKEN + TELEGRAM_ALLOWED_USERS).
A single script keeps the QR inside the expiry window — the link cannot be extended.8080 — and
very often 8081 too — are usually already taken on a real server (on the diyyourdata
box both were held by apache2, which makes signal-cli exit 3 /
Failed to initialize HTTP Server). Confirm a port is free before starting:
ss -tln | grep -E ':(8080|8081) ' # any match = taken; pick another, e.g. 8082
Also check for a stale daemon still running — it holds the account config lock, and the next
launch spins forever on Config file is in use by another instance, waiting…:
ps aux | grep '[o]rg.asamk.signal.Main daemon'
pkill -f 'signal-cli-<VERSION>.jar' # kill the stale one, then start cleanSIGNAL_CLI_OPTS=... signal-cli --account +<NUMBER> daemon --http 127.0.0.1:<PORT>
# verify it is up:
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:<PORT>/api/v1/check # 200
Install it as a systemd service for persistence across reboots — prefer this over a raw background process, and bind strictly to localhost (the daemon may otherwise also bind LAN interfaces). Example unit:
# /etc/systemd/system/signal-cli.service
[Unit]
Description=signal-cli HTTP daemon for Hermes gateway
After=network.target
[Service]
Type=simple
User=<account-owner> # same user that owns ~/.local/share/signal-cli
Environment=SIGNAL_CLI_OPTS=-Djava.library.path=/opt/signal-cli-<VERSION>/native
ExecStart=/opt/signal-cli-<VERSION>/bin/signal-cli daemon --http 127.0.0.1:<PORT>
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo cp /path/to/signal-cli.service /etc/systemd/system/ && sudo systemctl daemon-reload
sudo systemctl enable --now signal-cli
systemctl status signal-cli # Active: active (running)
hermes config set gateway.platforms.signal.enabled true
Add to ~/.hermes/.env (create/append; then chmod 600):
SIGNAL_HTTP_URL=http://127.0.0.1:<PORT>
SIGNAL_ACCOUNT=+<NUMBER>
SIGNAL_ALLOWED_USERS=+<NUMBER> # allowlist the operator; comma-separate for more
SIGNAL_HOME_CHANNEL=+<NUMBER> # default delivery target for cron jobs
# Optional groups: SIGNAL_GROUP_ALLOWED_USERS=<groupId1,groupId2> or * (omit to disable)
If python-dotenv warns “could not parse statement starting at line N”, inspect that line —
a stray lone = breaks parsing; remove it, but never remove real key lines. Then restart the
gateway (or hermes gateway install / sudo hermes gateway install --system for
persistence) and confirm in ~/.hermes/logs/gateway.log:
grep -iE "signal|platform" ~/.hermes/logs/gateway.log | tail
# look for "Signal: connected to http://127.0.0.1:<PORT>" AND "✓ signal connected"
# and "Gateway running with N platform(s)" that counts signal.
Send a live message. Because the daemon holds the config lock, the CLI
signal-cli ... send may block on “Config file is in use by another instance” — send via
the HTTP daemon JSON-RPC instead:
curl -s -m 40 -X POST http://127.0.0.1:<PORT>/api/v1/rpc -H "Content-Type: application/json" -d '{
"jsonrpc":"2.0",
"method":"send",
"params":{"account":"+<NUMBER>","message":"Test from Hermes","recipient":["+<NUMBER>"]},
"id":"test_send_1"
}'
# expect result.results[].type == "SUCCESS"
The recipient is the same account, so the message arrives in the operator's Signal app / Note to Self.
| # | Criterion |
|---|---|
| 1 | signal-cli --version prints 0.14.8+. |
| 2 | On aarch64: libsignal_jni.so is ELF ARM aarch64, and link/send run without the no signal_jni error. |
| 3 | signal-cli link yields a working URI and prints Associated with. |
| 4 | Daemon HTTP check returns 200 on a free port (confirm 8080/8081 aren't already taken). |
| 5 | gateway.log shows ✓ signal connected and Gateway running with N platform(s). |
| 6 | A JSON-RPC send returns SUCCESS and the operator receives it. |
Report back without secrets: architecture detected, signal-cli version, whether the aarch64
native lib was needed, daemon URL + health, gateway.log connection status, end-to-end test result,
and the names of the SIGNAL_* env vars that were set (not their values), plus how the daemon
and gateway are kept alive (persistent service or manual).