--- name: add-signal description: Add Signal channel integration via signal-cli device-link. Native adapter — no Chat SDK bridge. --- # Add Signal Channel Adds Signal support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) daemon — no Chat SDK bridge, only Node.js builtins. NanoClaw links to Signal as a *secondary device* on your existing phone: no new number, no bot API. Your assistant sends and receives as the number on the phone that scans the link. ## Apply ### 1. Install signal-cli NanoClaw talks to Signal through signal-cli, which has no bot API of its own. Install it if it isn't on PATH yet — Homebrew on macOS, the native release binary on Linux (neither needs Java). If it's already installed this is a no-op: ```nc:run effect:external command -v signal-cli >/dev/null 2>&1 || bash setup/install-signal-cli.sh ``` ### 2. Copy the adapter and its registration test Fetch the `channels` branch and copy the Signal adapter and its registration test into `src/channels/` (overwrite — the branch is canonical): ```nc:copy from-branch:channels src/channels/signal.ts src/channels/signal-registration.test.ts ``` ### 3. Register the adapter Append the self-registration import to the channel barrel (skipped if the line is already present). This one line is the skill's only reach-in into core: ```nc:append to:src/channels/index.ts import './signal.js'; ``` ### 4. Install the QR-rendering dependency The device-link step renders the linking URL as a terminal QR via `qrcode`. Pinned to exact versions — the supply-chain policy rejects ranges and `latest`: ```nc:dep qrcode@1.5.4 @types/qrcode@1.5.6 ``` The adapter itself consumes only Node.js builtins, so there is no adapter package to install — `qrcode` is purely for rendering the link during setup. ### 5. Build and validate Build first: it guards the adapter's typed core-API consumption. Then run the one integration test. ```nc:run effect:build pnpm run build ``` ```nc:run effect:test pnpm exec vitest run src/channels/signal-registration.test.ts ``` `signal-registration.test.ts` imports the real channel barrel and asserts the registry contains `signal`. It goes red if the `import './signal.js';` line is deleted or drifts, or if the barrel fails to evaluate — so the channel genuinely would not register. The adapter has no npm dependency to guard; its typed core-API consumption is covered by the build. End-to-end delivery against a real Signal account is verified manually once the service runs. ## Link your Signal account This is the whole credential step. signal-cli opens a device-link handshake, prints a `sgnl://linkdevice…` URL, and renders it as a scannable QR. You scan it once from the phone that already runs Signal; that phone's number becomes the account NanoClaw sends and receives as — no number is registered. The device-link runs signal-cli, so it must be reachable first — on `PATH`, or at `$SIGNAL_CLI_PATH`. If step 1's install didn't land, the link step has nothing to drive; confirm it's present before linking (re-run step 1 if this fails): ```nc:run effect:check command -v signal-cli >/dev/null 2>&1 || [ -x "$SIGNAL_CLI_PATH" ] ``` Tell the user: ```nc:operator Link NanoClaw to your Signal account: 1. On the phone that runs Signal, open Signal → Settings → Linked Devices → Link New Device. 2. Scan the QR code shown below — or open the `sgnl://linkdevice…` link printed under it on that phone. 3. Wait for confirmation. The linking URL expires after ~3 minutes; re-run this step for a fresh one. ``` Run the device-link. It blocks until you scan, then reports the linked phone number back as the account — that number is both your owner handle and the conversation address the wiring step needs: ```nc:run effect:step capture:platform_id=ACCOUNT,owner_handle=ACCOUNT pnpm exec tsx setup/index.ts --step signal-auth ``` `owner_handle` and `platform_id` both come back as the bare phone number (e.g. `+15551234567`). Your assistant reaches you through Signal's Note to Self, so the owner conversation is addressed by your own number — not a per-contact UUID. ## Persist the account Store the linked number so the adapter binds the right account on start, then sync it into the container env: ```nc:env-set SIGNAL_ACCOUNT={{platform_id}} ``` ## Restart Restart the service so it loads the Signal adapter and binds the account you just linked, and wait for its CLI socket before wiring: ```nc:run effect:restart bash setup/lib/restart.sh ``` ## Wiring ### DMs After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then: ```bash pnpm exec tsx scripts/q.ts data/v2.db \ "SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5" ``` Pass the `id` to `/init-first-agent` or `/manage-channels` to wire it to an agent group. ### Groups Add the Signal number to a group from your phone, send any message, then wire the resulting row the same way. Each group gets its own session with the default `shared` mode (one session per agent + messaging group). Create the wiring with `ncl` — **the host service must be running** (`ncl` connects to it over a Unix socket): ```bash # Engage mode/pattern default to the Signal adapter's declared channel defaults ncl wirings create --messaging-group-id mg-GROUPID --agent-group-id ag-AGENTID ``` ### Grant user access New Signal users (including the owner's Signal identity) are silently dropped with `not_member` until granted access. After the user's first message appears in `messaging_groups` (host service running): ```bash ncl users create --id "signal:UUID" --kind signal --display-name "" ncl roles grant --user "signal:UUID" --role owner ncl members add --user "signal:UUID" --group ag-AGENTID ``` Find the UUID from `messaging_groups.platform_id` or the `users` table. ## Next Steps If you're in the middle of `/setup`, return to the setup flow now. Otherwise wire this channel with `/init-first-agent` (or `/manage-channels`). ## Channel Info - **type**: `signal` - **terminology**: Signal has "chats" (1:1 DMs) and "groups." The owner reaches their own assistant through Note to Self. - **platform-id-format**: - Owner DM (Note to Self): the bare phone number `+` (e.g. `+15551234567`) — your own messages route back as inbound with `isFromMe`, addressed by your number. - Third-party DM: `signal:{UUID}` — the sender's Signal ACI, **not** their phone number. - Group: `signal:{base64GroupId}` — base64-encoded GroupV2 ID. - **how-to-find-id**: The owner number comes back from the device-link step above. For third parties or groups, send a message to the bot, then query `messaging_groups`. - **supports-threads**: no - **typical-use**: Personal assistant via Signal DMs or small group chats - **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically get their own agent group (the default `shared` session mode already gives each messaging group its own session). ### Features - Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles). - Quoted replies — `replyTo*` fields populated from Signal quotes. - Typing indicators — DMs only (Signal doesn't support group typing). - Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true`. - Voice attachments — detected but not transcribed by default; the agent receives a `[Voice Message]` placeholder. Run `/add-voice-transcription` for local transcription. Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions. ## Alternatives ### Register a dedicated number instead of linking The device-link above joins Signal as a *secondary device* on an existing number. If you'd rather give the assistant its own number, register a dedicated SIM or VoIP number that NanoClaw owns entirely. This path takes a captcha, an SMS (or voice) verification, and an optional profile name. > **VoIP numbers:** Signal requires SMS verification before voice. Some VoIP providers are blocked even for voice calls. If registration fails with an auth error, try a different provider or a physical SIM. **Step 1: Solve the CAPTCHA** Signal requires a CAPTCHA on first registration: 1. Open `https://signalcaptchas.org/registration/generate.html` in a browser 2. Solve the captcha 3. Right-click the **"Open Signal"** button → **Copy Link** 4. The link starts with `signalcaptcha://` — the token is everything after that prefix **Step 2: Request SMS verification** ```bash signal-cli -a +1YOURNUMBER register --captcha "PASTE_TOKEN_HERE" ``` **Step 3: Voice call fallback (if your number can't receive SMS)** Wait ~60 seconds after the SMS request, then: ```bash signal-cli -a +1YOURNUMBER register --voice --captcha "SAME_TOKEN" ``` Signal calls your number and reads a 6-digit code. The same captcha token is reusable — no need to solve a new one. > You must request SMS first. Requesting voice immediately fails with `Invalid verification method: Before requesting voice verification…` **Step 4: Verify** ```bash signal-cli -a +1YOURNUMBER verify CODE ``` No output = success. **Step 5: Set profile name (optional)** > ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running. Run from your NanoClaw project root: ```bash source setup/lib/install-slug.sh # macOS launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName" # optionally: --avatar /path/to/avatar.jpg launchctl load ~/Library/LaunchAgents/$(launchd_label).plist # Linux systemctl --user stop $(systemd_unit) signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName" systemctl --user start $(systemd_unit) ``` Once registered, set `SIGNAL_ACCOUNT` to this number (as under **Persist the account** above) and restart the service. ## Optional configuration These `.env` keys tune how NanoClaw talks to the signal-cli daemon. All are optional — the defaults work for the device-link flow above. ```bash # TCP daemon host and port (default: 127.0.0.1:7583) SIGNAL_TCP_HOST=127.0.0.1 SIGNAL_TCP_PORT=7583 # Path to the signal-cli binary (default: resolved on PATH) SIGNAL_CLI_PATH=/usr/local/bin/signal-cli # Whether NanoClaw manages the daemon lifecycle (default: true). # Set to false if you run signal-cli daemon externally. SIGNAL_MANAGE_DAEMON=true # signal-cli data directory (default: ~/.local/share/signal-cli) SIGNAL_DATA_DIR=~/.local/share/signal-cli ``` **Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network. ## Troubleshooting ### Daemon not reachable ```bash grep "Signal" logs/nanoclaw.log | tail ``` If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`: - Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`) - Confirm the account is linked: `signal-cli -a +YOURNUMBER listIdentities` should succeed without prompting If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +YOURNUMBER daemon --tcp 127.0.0.1:7583`. ### Bot not responding 1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1` 2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` 3. Service running: `launchctl print gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux) 4. **Check for duplicate service instances** — if `logs/nanoclaw.error.log` shows `No adapter for channel type channelType="signal"` despite the adapter starting, two NanoClaw processes are racing. See the `/debug` skill section "No adapter for channel type / Messages silently lost" for the full fix. ### Messages delivered but never arrive (null platformMsgId) Signal responses show `platformMsgId=undefined` in the main log. This means the delivery poll ran but found no adapter — likely a duplicate service instance issue (see above). Affected messages cannot be retried; the user must resend. ### Lost connection mid-session If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped the connection. Restart the service to re-establish. ### Messages dropped with `not_member` The Signal user hasn't been granted membership. New Signal senders — including the owner's Signal identity — are gated until granted access. `/init-first-agent` grants the owner automatically; for other users, grant access as shown under **Grant user access** in the Wiring section (or via `/manage-channels`) after their first message appears in `messaging_groups`. This affects every new Signal user, since their Signal identity is a separate user record from their identity on other channels even if it's the same person. ### Captcha required Signal requires a captcha for new registrations. Go to `https://signalcaptchas.org/registration/generate.html`, solve it, right-click "Open Signal", copy the link, extract the token after `signalcaptcha://`. ### `Invalid verification method: Before requesting voice verification…` You must request SMS first, wait ~60 seconds, then request voice. Both steps can use the same captcha token. ### Config file in use / daemon lock signal-cli holds an exclusive lock on its data directory while the daemon is running. Stop NanoClaw before running any `signal-cli` commands directly, then restart afterward. ### Group replies going to DM instead of group Modern Signal groups use GroupV2. The adapter must extract the group ID from `envelope?.dataMessage?.groupV2?.id` — not `groupInfo?.groupId`, which is GroupV1/legacy. If group messages are routing as DMs, check `src/channels/signal.ts` and confirm the groupId extraction falls through to `groupV2.id`. ### QR / linking URL expired The `sgnl://linkdevice…` URL (and the Path A registration captcha) expire after a few minutes. Re-run the device-link step to get a fresh QR.