Wintermute can connect to Signal messenger via signal-cli, a command-line interface for Signal that uses the Signal protocol directly. Wintermute spawns signal-cli in HTTP daemon mode (daemon --http) and communicates via HTTP JSON-RPC for sending and SSE (Server-Sent Events) for receiving messages.
- JRE 25+ (signal-cli requires Java)
- Fedora:
sudo dnf install java-25-openjdk-headless - Debian/Ubuntu:
sudo apt install openjdk-25-jre-headless
- Fedora:
- signal-cli (download from GitHub releases)
- (Optional) qrencode — for rendering QR codes in the terminal when linking as a secondary device
- Fedora:
sudo dnf install qrencode - Debian/Ubuntu:
sudo apt install qrencode
- Fedora:
- (Optional) ffmpeg — required for voice message transcription (same as Matrix)
Download the latest release and extract it:
# Check https://github.com/AsamK/signal-cli/releases for the latest version
VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e '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/Verify installation:
signal-cli --versionYou need a phone number for the bot. There are two options:
Use a dedicated phone number (SIM card, VoIP number, or SIP number):
# Register via SMS
signal-cli -a +1234567890 register
# Or register via voice call (works with landlines/SIP numbers)
signal-cli -a +1234567890 register --voice
# Verify with the code you received
signal-cli -a +1234567890 verify 123-456This is fully headless — no QR code or smartphone involved. The bot gets its own independent Signal identity.
Link signal-cli to your existing Signal account as a secondary device:
# Generate a linking URI
signal-cli link -n "Wintermute"This outputs a sgnl://linkdevice?uuid=...&pub_key=... URI. On a headless system, render it as a terminal QR code:
signal-cli link -n "Wintermute" | tee >(xargs -L 1 qrencode -t utf8)The QR code renders as UTF-8 art visible in SSH terminals or journalctl output. Scan it with the Signal app on your primary phone to complete linking.
Note: When linked as a secondary device, the bot inherits your primary's groups and contacts but cannot register for new groups independently.
Send a test message to verify everything works:
signal-cli -a +1234567890 send -m "Hello from Wintermute" +0987654321Add the following to your config.yaml:
signal:
enabled: true
phone_number: "+1234567890" # Bot's registered Signal number
signal_cli_path: "signal-cli" # Path to signal-cli binary
allowed_users: ["+0987654321"] # Phone numbers or UUIDs allowed to interact
allowed_groups: [] # Group IDs (empty = allow all)
group_mode: false # Only respond when mentioned
trust_new_keys: true # Auto-trust new identity keys
http_port: 8190 # Port for signal-cli HTTP daemonallowed_users accepts both phone numbers and Signal UUIDs. This is important because Signal allows users to hide their phone number — in that case, only their UUID is available in the message envelope, and phone-number-based allowlisting will silently reject the message.
If a user has no visible phone number, you must use their UUID in allowed_users.
allowed_users:
- "+491234567890" # Phone number
- "a1b2c3d4-e5f6-7890-abcd-ef1234567890" # UUID (required if phone is hidden)If the list is empty, all users are allowed.
Finding a user's UUID:
Signal UUIDs aren't visible in the app. There are three ways to find them:
-
From Wintermute's logs (easiest) — temporarily remove the user from
allowed_usersor leave the list empty, then have them send a message. The log will show:[signal] User (none) (uuid=a1b2c3d4-e5f6-7890-abcd-ef1234567890) not in allowed_users, ignoringCopy the full UUID into your config.
-
Via signal-cli — list all known contacts with their UUIDs:
signal-cli -a +1234567890 listContacts
Or look up a specific number:
signal-cli -a +1234567890 getUserStatus +0987654321
-
signal-cli data directory — stored in
~/.local/share/signal-cli/data/, but the methods above are easier.
- 1:1 chats:
sig_+491234567890(phone) orsig_<uuid>(UUID) - Groups:
sig_group_<base64-group-id>
allowed_groups uses signal-cli's internal base64-encoded group IDs. To find them:
-
Via signal-cli (easiest):
signal-cli -a +1234567890 listGroups
This prints all groups with their IDs, names, and members. Copy the
Idvalue into your config. -
From Wintermute's logs — if
allowed_groupsis empty (allow all), send a message in the group and look for the thread_id in the logs. The group ID is the base64 value aftersig_group_.
allowed_groups:
- "mQ5xR7k3bT..." # Base64 group ID from listGroupsIf the list is empty, all groups are allowed (1:1 mode default).
When group_mode: true, the bot only responds to messages that mention its phone number. Each mention is a single-turn conversation (no prior history sent to the LLM). Sender attribution is included as [+491234567890]: message.
allowed_groups must be set when group mode is enabled to prevent unintended data collection.
By default, trust_new_keys: true tells signal-cli to automatically trust new identity keys (--trust-new-identities always). This is required for headless bot operation — without it, signal-cli would block on identity key changes requiring interactive confirmation.
If you want stricter trust (manual key verification), set trust_new_keys: false and manage identity trust via signal-cli commands directly.
signal-cli requires JRE 25+. Check your version:
java -versionIf you have multiple Java versions, set JAVA_HOME or use the full path in signal_cli_path.
On some systems, signal-cli may fail to load the native libsignal library. Ensure you're using the correct architecture (x86_64 vs aarch64) release of signal-cli.
signal-cli stores received attachments in ~/.local/share/signal-cli/attachments/. Wintermute reads them from the paths provided in the SSE event data. Ensure the Wintermute process has read access to this directory.
Wintermute spawns signal-cli as a subprocess in HTTP daemon mode and manages its lifecycle. On startup, it polls /api/v1/check until the daemon is ready (up to 60s). If signal-cli crashes, Wintermute automatically restarts it with exponential backoff (1s, 2s, 4s, ... up to 60s). If only the SSE stream disconnects while the process is alive, Wintermute reconnects the stream without restarting signal-cli.
To check if signal-cli is running independently:
signal-cli -a +1234567890 daemon --http 127.0.0.1:8190 --receive-mode on-startVoice message transcription requires the same Whisper setup as Matrix. See the whisper: section in config.yaml.example. ffmpeg must be installed for audio format conversion.