Signal is a downloadable channel plugin (@openclaw/signal). The gateway talks to signal-cli over HTTP: either the native daemon (JSON-RPC + SSE) or the bbernhard/signal-cli-rest-api container (REST + WebSocket). Apus does not embed libsignal.

The number model (read this first)

Install

apus plugins install @openclaw/signal

@openclaw/signal installs from npm first, then falls back to its declared ClawHub package only when the npm target is unavailable. Use npm: or clawhub: to force a source. plugins install registers and enables the plugin; no separate enable step is needed. See Plugins for general install rules.

Quick setup

Pick a number

Use a **separate Signal number** for the bot (recommended).

Install the plugin

```bash
apus plugins install @openclaw/signal
```

Run the guided setup

```bash
apus channels add
```
The wizard detects whether `signal-cli` is on `PATH` and, when missing, offers to install it: downloads the official native GraalVM build on Linux x86-64, or installs via Homebrew on macOS and other architectures. It then prompts for the bot number and `signal-cli` path.

For non-interactive setup, `apus channels add --channel signal` also accepts `--signal-number <e164>` for the bot phone number, plus `--http-host <host>` and `--http-port <port>` for the Signal daemon endpoint (default `127.0.0.1:8080`).

Link or register the account

- **QR link (fastest):** `signal-cli link -n "Apus"`, then scan with Signal. See [Path A](#setup-path-a-link-existing-signal-account-qr).
- **SMS registration:** dedicated number with captcha + SMS verification. See [Path B](#setup-path-b-register-dedicated-bot-number-sms-linux).

Verify and pair

```bash
apus channels status --probe
```
Send a first DM and approve pairing: `apus pairing approve signal <CODE>`.

Minimal config:

{
  channels: {
    signal: {
      enabled: true,
      account: "+15551234567",
      transport: {
        kind: "managed-native",
        cliPath: "signal-cli",
      },
      dmPolicy: "pairing",
      allowFrom: ["+15557654321"],
    },
  },
}
Field Description
account Bot phone number in E.164 format (+15551234567)
transport Account-owned Signal connection and process mode
dmPolicy DM access policy (pairing recommended)
allowFrom Phone numbers or uuid:<id> values allowed to DM

Multi-account support: use channels.signal.accounts with per-account config and optional name. Each named account owns its transport; it does not inherit the top-level transport. The top-level transport belongs only to the implicit default account. See Multi-account channels for the shared pattern.

Account keys use the normalized IDs shown by status. For example, Work Phone with its own account number runs as work-phone and uses its authored settings without running Doctor. If multiple keys normalize to the same ID, the exact key wins and Doctor reports the collision. Deletion refuses to remove that account if another stored key would then select a different identity; the error names both keys so you can resolve the collision first. Legacy aliases without their own number keep their existing inherited behavior. Doctor can clean up unambiguous keys, but refuses to rename an alias when that would activate previously ignored settings.

Omitted account dmPolicy and groupPolicy inherit the channel root; explicit account policies win. If neither scope sets them, DMs use pairing and groups use allowlist.

What it is

Setup path A: link existing Signal account (QR)

  1. Install signal-cli (JVM or native build), or let apus channels add install it for you.
  2. Link a bot account: signal-cli link -n "Apus", then scan the QR in Signal.
  3. Configure Signal and start the gateway.

Setup path B: register dedicated bot number (SMS, Linux)

Use this for a dedicated bot number instead of linking an existing Signal app account. The flow below is tested on Ubuntu 24.

  1. Get a number that can receive SMS (or voice verification for landlines). A dedicated bot number avoids account/session conflicts.
  2. Install signal-cli on the gateway host:
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}-Linux-native.tar.gz"
sudo tar xf "signal-cli-${VERSION}-Linux-native.tar.gz" -C /opt
sudo ln -sf /opt/signal-cli /usr/local/bin/
signal-cli --version

If you use the JVM build (signal-cli-${VERSION}.tar.gz), install a JRE first. Keep signal-cli updated; upstream notes old releases can break as Signal server APIs change.

  1. Register and verify the number:
signal-cli -a +<BOT_PHONE_NUMBER> register

Still inside step 3, if captcha is required (browser access is needed to complete this step):

signal-cli -a +<BOT_PHONE_NUMBER> register --captcha '<SIGNALCAPTCHA_URL>'
signal-cli -a +<BOT_PHONE_NUMBER> verify <VERIFICATION_CODE>
  1. Configure Apus and verify the channel. Config changes follow hot reload; start the Gateway if it is offline. Restart it if you changed the service's PATH to find signal-cli.
apus doctor
apus channels status --probe
  1. Pair your DM sender:
    • Send any message to the bot number.
    • Approve on the server: apus pairing approve signal <PAIRING_CODE>.
    • Save the bot number as a contact on your phone to avoid "Unknown contact".

Registering a phone number account with signal-cli can de-authenticate the main Signal app session for that number. Prefer a dedicated bot number, or use QR link mode to keep your existing phone app setup.

Upstream references:

External native daemon mode

To manage signal-cli yourself (slow JVM cold starts, container init, shared CPUs), run the daemon separately and point Apus at it:

For non-interactive setup, select the endpoint kind explicitly when needed:

apus channels add --channel signal --signal-number +15551234567 \
  --http-url http://127.0.0.1:8080 --signal-transport external-native
{
  channels: {
    signal: {
      transport: {
        kind: "external-native",
        url: "http://127.0.0.1:8080",
      },
    },
  },
}

This skips auto-spawn and Apus's startup wait. For a managed daemon with a slow start, set channels.signal.transport.startupTimeoutMs.

Container mode (bbernhard/signal-cli-rest-api)

Instead of running signal-cli natively, use the bbernhard/signal-cli-rest-api Docker container, which wraps signal-cli behind a REST + WebSocket interface.

apus channels add --channel signal --signal-number +15551234567 \
  --http-url http://signal-cli:8080 --signal-transport container

Requirements:

Example docker-compose.yml service:

signal-cli:
  image: bbernhard/signal-cli-rest-api:latest
  environment:
    MODE: json-rpc
  ports:
    - "8080:8080"
  volumes:
    - signal-cli-data:/home/.local/share/signal-cli

Apus config:

{
  channels: {
    signal: {
      enabled: true,
      account: "+15551234567",
      transport: {
        kind: "container",
        url: "http://signal-cli:8080",
      },
    },
  },
}

transport.kind controls which protocol and process lifecycle Apus uses:

Value Behavior
"managed-native" Start native signal-cli and use JSON-RPC at /api/v1/rpc plus SSE at /api/v1/events; url may select a connection endpoint distinct from the daemon bind
"external-native" Connect to an already-running native signal-cli daemon
"container" Connect to bbernhard REST at /v2/send and WebSocket at /v1/receive/{account}

Setup and apus doctor --fix may check an existing endpoint once to identify its concrete kind. Runtime operations do not auto-detect or switch protocols.

Container mode supports the same Signal operations as native mode where the container exposes matching APIs: sends, receives, attachments, typing indicators, read/viewed receipts, reactions, groups, and styled text. Apus translates native Signal RPC calls into the container's REST payloads, including group.{base64(internal_id)} group IDs and text_mode: "styled" for formatted text.

Operational notes:

Opt-in private UNIX socket

On POSIX systems, a managed native daemon can use a UNIX socket instead of an HTTP listener. This is useful on shared hosts where other OS users must not control the Signal daemon. Configure transport.socketPath with an absolute path (at most 103 UTF-8 bytes, without . or .. path segments):

{
  channels: {
    signal: {
      enabled: true,
      account: "+15555550123",
      transport: {
        kind: "managed-native",
        socketPath: "/home/user/.local/state/signal-private/daemon.sock",
      },
    },
  },
}

The socket's immediate directory must belong to the Gateway OS user and have mode 0700 (no group or other access). Apus creates that directory if it is missing and its parent already exists; it does not repair permissions on existing directories. Symlink paths are rejected. On macOS, Apus also inspects ACLs beyond the BSD mode bits and rejects access-granting ACL entries for non-owners, including inheritable entries. On Linux, POSIX ACLs are not inspected; operators should select a parent hierarchy with no access-granting ACLs. Use a distinct socket path for each account. Windows is not supported for this opt-in.

socketPath is only valid with kind: "managed-native" and cannot be combined with url, httpHost, or httpPort. Omit receiveMode or set it to "manual"; Apus manages receive subscriptions over the socket. Socket connection or permission failures stop this transport; there is no HTTP fallback.

Existing managed HTTP defaults, external native daemons, and container setups are unchanged. Opting out leaves the existing HTTP exposure unchanged: a loopback bind does not prevent another local OS user from reaching an unauthenticated daemon. The private directory separates OS users, not processes running as the same user or administrators. signal-cli does not authorize a connecting peer's UID, so this isolation guarantee depends on filesystem authorization. Signal sender pairing and allowlists remain separate message-access controls.

Access control (DMs + groups)

DMs:

Groups:

Mention-gated group with bounded context:

{
  channels: {
    signal: {
      account: "+15551234567",
      accountUuid: "bot-signal-uuid",
      groupPolicy: "allowlist",
      groupAllowFrom: ["group:<signal-group-id>"],
      historyLimit: 8,
      groups: {
        "<signal-group-id>": { requireMention: true },
      },
    },
  },
  messages: {
    groupChat: {
      mentionPatterns: ["\\bapus\\b"],
    },
  },
}

Allowed group messages that do not mention the bot stay silent and are kept only in the bounded pending history window. When a later native @mention or fallback text mention triggers the bot, Apus includes that recent context and replies to the same group. Skipped attachment bodies are not downloaded; they may appear only as compact media placeholders in the pending context.

How it works (behavior)

Media + limits

Typing + read receipts

Lifecycle status reactions

Set messages.statusReactions.enabled: true to let Signal show the shared queued/thinking/tool/compaction/done/error reaction lifecycle on inbound turns. Signal uses the inbound message timestamp as the reaction target; group reactions are sent with the Signal group ID plus the original sender as the target author.

Status reactions also require an ack reaction and a matching messages.ackReactionScope (direct, group-all, group-mentions, or all). Set channels.signal.reactionLevel: "off" to disable Signal status reactions.

Signal restores the initial ack reaction after the final done/error state.

Reactions (message tool)

Use message action=react with channel=signal.

message action=react channel=signal target=uuid:123e4567-e89b-12d3-a456-426614174000 messageId=1737630212345 emoji=🔥
message action=react channel=signal target=+15551234567 messageId=1737630212345 emoji=🔥 remove=true
message action=react channel=signal target=signal:group:<groupId> targetAuthor=uuid:<sender-uuid> messageId=1737630212345 emoji=✅

Config:

Approval reactions

Signal exec and plugin approval prompts use the top-level approvals.exec and approvals.plugin routing blocks. Signal has no channels.signal.execApprovals block.

Approval reaction resolution requires explicit Signal approvers from channels.signal.allowFrom, channels.signal.defaultTo, or the matching account-level fields. Direct same-chat exec approval prompts can still suppress the duplicate local /approve fallback without explicit approvers; no-approver group approvals keep the local fallback visible.

Question reactions

For an ask_user prompt with one non-secret, single-select question and one to four options, Signal shows 1️⃣ through 4️⃣ beside the option labels. React to the delivered prompt with the matching number to answer it. Apus verifies the reaction targets the bot-authored message, then maps the number to the canonical option through the Gateway. Stale or duplicate taps are ignored. Multi-question, multi-select, and free-text prompts remain text-reply-only; normal Signal DM/group admission rules authorize the sender.

Delivery targets (CLI/cron)

Aliases

Configure aliases for stable names on recurring Signal targets. Aliases are Apus-side config only; they do not create or edit Signal contacts.

{
  channels: {
    signal: {
      aliases: {
        me: "+15557654321",
        jane: "uuid:123e4567-e89b-12d3-a456-426614174000",
        ops: "group:<groupId>",
      },
      defaultTo: "signal:me",
    },
  },
}

Use aliases anywhere Signal delivery targets are accepted:

apus message send --channel signal --target signal:ops --message "Deployment is complete"

Per-account aliases inherit the top-level aliases and can add or override names:

{
  channels: {
    signal: {
      aliases: {
        me: "+15557654321",
      },
      accounts: {
        work: {
          aliases: {
            ops: "group:<workGroupId>",
          },
        },
      },
    },
  },
}

apus directory peers list --channel signal and apus directory groups list --channel signal list configured aliases. The Signal directory is config-backed; it does not live-query Signal contacts or mutate the Signal account.

Troubleshooting

Run this ladder first:

apus status
apus gateway status
apus logs --follow
apus doctor
apus channels status --probe

Then confirm DM pairing state if needed:

apus pairing list signal

Common failures:

Extra checks:

apus pairing list signal
pgrep -af signal-cli
apus logs --plain --limit 500 | grep -i "signal" | tail -20

For triage flow: Channels Troubleshooting.

Security notes

Configuration reference (Signal)

Full configuration: Configuration

Provider options:

Related global options:

Related