Signal Channel

Calciforge’s Signal channel embeds zeroclawlabs::SignalChannel as a library and talks directly to signal-cli-rest-api or any compatible signal-cli daemon --http front-end. There is no separate ZeroClaw daemon in the runtime path.

The wire-protocol contract is signal-cli’s JSON-RPC + SSE API. JSON-RPC is a structured request/response format; SSE, or Server-Sent Events, is the stream used for incoming messages. As long as your daemon implements that (signal-cli-rest-api is the reference; any compatible re-implementation works), Calciforge will connect.

What this gateway does

signal-cli-rest-api owns the Signal session: registration, encryption, and the libsignal store. It exposes that session over HTTP. zeroclawlabs::SignalChannel is the Rust client that subscribes to the SSE event stream for inbound messages and POSTs JSON-RPC send requests for outbound replies. Calciforge wires that client into its identity resolver, command dispatcher, and agent router.

ZeroClaw is no longer required. signal-cli-rest-api is the only external dependency, and it is a generic Signal automation tool — not Calciforge- or agent-specific.

Architecture

Signal user  ──→  signal-cli-rest-api  ──→  zeroclawlabs::SignalChannel  ──→  Calciforge

Inbound and outbound flow through the same daemon over HTTP/SSE; the SSE listener and the JSON-RPC sender live inside the Calciforge process.

Prerequisites

Step 1: Channel config

Add to ~/.config/calciforge/config.toml:

[[channels]]
kind = "signal"
enabled = true

# HTTP URL of signal-cli-rest-api. Calciforge subscribes to SSE events at
# {url}/api/v1/events and sends via JSON-RPC at {url}/api/v1/rpc.
signal_cli_url = "http://127.0.0.1:8080"

# The bot's registered Signal number (E.164).
signal_account = "+15555550001"

# Allowed sender phone numbers in E.164 format. Must match identity aliases
# below. Use "*" to allow any number (not recommended).
allowed_numbers = ["+15555550001"]

# Optional: restrict to a single Signal group. Replies go back to that group.
# Use the literal "dm" to filter to direct messages only.
# signal_group_id = "group.abc123…"

# Optional: drop attachment-only messages (no text body). Default false.
# signal_ignore_attachments = false

# Optional: drop Signal "story" messages. Default false.
# signal_ignore_stories = false

# Optional: force plain text choice rendering. Default "auto".
# The current embedded Signal backend is text-first either way.
# ui_mode = "text"
Field Required Default Description
signal_cli_url yes HTTP URL of signal-cli-rest-api
signal_account yes Bot’s registered Signal number (E.164)
allowed_numbers yes [] E.164 senders allowed to interact
signal_group_id no Restrict to a specific group; or "dm" for DMs only
signal_ignore_attachments no false Drop attachment-only messages
signal_ignore_stories no false Drop story messages
ui_mode no "auto" Reserved for channel-native controls; set "text" to force plain text fallback
scan_messages no false Enable inbound adversarial content scanning

Migrating from the legacy webhook config

The previous Signal channel was a webhook receiver that forwarded replies through a separate ZeroClaw daemon. If your config still has these fields:

…remove them from the [[channels]] block where kind = "signal" and replace with the new shape above. These fields are also rejected by the embedded WhatsApp channel; neither Signal nor WhatsApp uses a Calciforge webhook sidecar in the current schema.

Step 2: Identity config

The alias id is the E.164 phone number. The leading + is required:

[[identities]]
id = "operator"
display_name = "Alice"
role = "admin"
aliases = [
    { channel = "signal", id = "+15555550001" },
]

[[routing]]
identity = "operator"
default_agent = "primary-agent"
allowed_agents = ["primary-agent"]

Phone numbers in allowed_numbers that don’t match any identity alias are silently dropped at the auth boundary.

Step 3: Run signal-cli-rest-api

The standard deployment is the upstream Docker image. Bind the published port to loopback so the unauthenticated Signal session API is reachable only from the host running Calciforge:

docker run -d --name signal-api \
  -p 127.0.0.1:8080:8080 \
  -v signal-cli-config:/home/.local/share/signal-cli \
  -e MODE=json-rpc \
  bbernhard/signal-cli-rest-api

Do not publish signal-cli-rest-api on a routable interface unless you put it behind a trusted reverse proxy, firewall, or authentication layer. Calciforge’s allowed_numbers, identity routing, and message-scanning controls apply after Calciforge receives events; they do not protect clients that can talk directly to the signal-cli-rest-api session API.

MODE=json-rpc is required — Calciforge talks JSON-RPC + SSE, not the older REST endpoints.

Step 4: Verify

calciforge doctor   # validates config
calciforge          # start; send a Signal message from an allowed number

Check logs for Signal channel listening via SSE on …. A health check on signal-cli-rest-api itself:

curl http://127.0.0.1:8080/v1/health

Channel UI

Signal is a high-priority channel for future richer controls, but the current interface should be treated as text-first. Agent choices, model choices, session lists, and approval decisions all render through the shared choice model, but the embedded Signal transport sends the text fallback because zeroclawlabs::Channel::SendMessage does not expose Signal-native controls. Keep using deterministic commands such as !agent switch <id>, `!model use

`, `!switch `, `!approve `, and `!deny ` until the Signal transport exposes safe native affordances. The commands are not fancy, but they are clear, logged, and hard to mis-tap. You can also use Telegram as the Calciforge control surface for buttons while continuing the main chat in Signal. Active agent/model selections are keyed by Calciforge identity, so choices made through Telegram apply to the same operator's Signal route.
Signal text fallback for agent and model selection
Signal remains text-first while richer channel controls are evaluated.