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.
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.
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.
signal-cli-rest-api (or signal-cli daemon --http) with a
registered Signal account. See the project README for registration steps
(one-time SMS or QR-link).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 |
The previous Signal channel was a webhook receiver that forwarded replies through a separate ZeroClaw daemon. If your config still has these fields:
zeroclaw_endpoint, zeroclaw_auth_tokenwebhook_listen, webhook_path, webhook_secret…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.
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.
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.
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
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