Summary
The current Signal channel implementation works well with directly-installed signal-cli using JSON-RPC and SSE endpoints (/api/v1/rpc and /api/v1/events). However, these endpoints do not exist in the official bbernhard/signal-cli-rest-api Docker image, which only provides REST + WebSocket endpoints.
We should add support for the REST API mode while keeping the existing JSON-RPC mode working, giving users flexibility to choose based on their deployment scenario.
Current situation:
- JSON-RPC mode works - For users who install signal-cli directly on the host
- REST API mode doesn't work - For users who want containerized deployments with
bbernhard/signal-cli-rest-api
- No configuration option - Users cannot choose which API mode to use
Current problems with containerized deployments:
-
Non-existent endpoints: Gateway tries to connect to /api/v1/events (SSE) and /api/v1/rpc (JSON-RPC), but the REST API image only exposes /v1/receive/{account} (WebSocket) and /v2/send (REST)
-
Connection failures: Logs show repeated 404 errors:
[signal] SSE stream error: Error: Signal SSE failed (404 Not Found)
[signal] SSE connection lost, reconnecting in 10s...
-
Workarounds required: Users must choose between:
- Running signal-cli directly on the host (requires Java, complex setup)
- Using a custom wrapper/bridge service (maintenance burden)
- Modifying the REST API Docker image (non-standard deployment)
-
Missing "Note to Self" support: The current implementation doesn't properly handle syncMessage.sentMessage (self-messages), which is needed for the "Note to Self" workflow where users talk to the bot via their own Signal account
Proposed solution
Add REST API support alongside existing JSON-RPC, with automatic detection and manual configuration:
Configuration Option
Add apiMode setting to Signal channel config:
{
"channels": {
"signal": {
"httpUrl": "http://localhost:8080",
"account": "+1234567890",
"apiMode": "auto" // "auto" (default), "jsonrpc", or "rest"
}
}
}
API Mode Options:
"auto" (default) - Auto-detect by probing available endpoints (try REST first, fall back to JSON-RPC)
"jsonrpc" - Force legacy JSON-RPC + SSE mode (for direct signal-cli installations)
"rest" - Force REST + WebSocket mode (for bbernhard/signal-cli-rest-api containers)
Dual Implementation
Support both API modes in parallel:
JSON-RPC Mode (existing, keep as-is)
Receiving messages:
Sending messages:
POST /api/v1/rpc with method send
Typing indicators:
POST /api/v1/rpc with method sendTyping
Read receipts:
POST /api/v1/rpc with method sendReceipt
Health checks:
REST Mode (new)
Receiving messages:
GET /v1/receive/{account} (WebSocket)
Sending messages:
Typing indicators:
PUT /v1/typing-indicator/{phone} (REST)
Read receipts:
POST /v1/receipts/{phone} (REST)
Health checks:
Event Format Handling
The event handler should support both formats:
SSE format (JSON-RPC mode):
{
"event": "receive",
"data": "{\"envelope\":{\"sourceNumber\":\"+1234567890\",\"dataMessage\":{\"message\":\"Hello\"}}}"
}
WebSocket format (REST mode):
{
"envelope": {
"sourceNumber": "+1234567890",
"dataMessage": {
"message": "Hello"
}
}
}
Critical syncMessage Handling (both modes)
Both modes should properly handle syncMessage to support "Note to Self":
// Extract dataMessage from syncMessage.sentMessage if present
let dataMessage = envelope.dataMessage ?? envelope.editMessage?.dataMessage;
let isFromSync = false;
if (envelope.syncMessage && !dataMessage) {
const sentMessage = envelope.syncMessage.sentMessage;
if (sentMessage) {
dataMessage = sentMessage;
isFromSync = true;
}
}
// Self-message filter: block bot echoes, allow Note to Self
if (sender.e164 === normalizeE164(account)) {
if (!isFromSync) {
return; // dataMessage from self = bot echo (block it)
}
// isFromSync = Note to Self (allow it)
}
Benefits
- Backward compatibility: Existing users with direct signal-cli installations continue to work
- Containerized deployments: New users can use official
bbernhard/signal-cli-rest-api:latest Docker image
- Flexible deployment: Users choose the mode that fits their infrastructure
- Automatic detection: Zero-config setup for most users (auto-detect works)
- Better reliability: WebSocket (REST mode) provides persistent connection vs. SSE polling
- Note to Self support: Users can talk to their bot via self-messages (both modes)
- Easier onboarding:
docker-compose up just works for containerized setups
Use Cases
Use Case 1: Existing host-based deployment (JSON-RPC)
{
"channels": {
"signal": {
"httpUrl": "http://localhost:8080",
"account": "+1234567890",
"apiMode": "jsonrpc" // or "auto" (will detect JSON-RPC)
}
}
}
Use Case 2: New containerized deployment (REST)
# docker-compose.yml
services:
signal-api:
image: bbernhard/signal-cli-rest-api:latest
ports:
- "8080:8080"
openclaw-gateway:
image: openclaw:latest
environment:
OPENCLAW_SIGNAL_HTTP_URL: http://signal-api:8080
OPENCLAW_SIGNAL_ACCOUNT: "+1234567890"
# apiMode: "auto" will detect REST API
Use Case 3: Mixed environment
- Some accounts use JSON-RPC (direct signal-cli on host)
- Other accounts use REST (containerized signal-cli-rest-api)
{
"channels": {
"signal": {
"accounts": {
"personal": {
"httpUrl": "http://localhost:8080",
"account": "+1234567890",
"apiMode": "jsonrpc"
},
"work": {
"httpUrl": "http://signal-container:8080",
"account": "+9876543210",
"apiMode": "rest"
}
}
}
}
}
Alternatives considered
- Replace JSON-RPC entirely: Would break existing users ❌
- Only support one mode: Users have to adapt their infrastructure to OpenClaw ❌
- Separate channels (signal-jsonrpc vs signal-rest): Code duplication, confusing UX ❌
- Custom bridge service: External dependency, maintenance burden ❌
Implementation plan
- ✅ Add
apiMode configuration option to SignalAccountConfig
- ✅ Implement
detectSignalApiMode() to probe available endpoints
- ✅ Add REST API client functions (
signalRestRequest, streamSignalEvents)
- ✅ Update event handler to support both SSE and WebSocket formats
- ✅ Add syncMessage extraction for "Note to Self" support
- ✅ Add self-message filtering (block bot echoes, allow Note to Self)
- ✅ Update send operations to support both JSON-RPC and REST
- ✅ Add unit tests for both modes
- 🔲 Update documentation with configuration examples
- 🔲 Add migration guide for users switching between modes
Additional context
Note: The implementation should maintain full backward compatibility. Existing configurations without apiMode specified should continue to work via auto-detection.
Summary
The current Signal channel implementation works well with directly-installed
signal-cliusing JSON-RPC and SSE endpoints (/api/v1/rpcand/api/v1/events). However, these endpoints do not exist in the officialbbernhard/signal-cli-rest-apiDocker image, which only provides REST + WebSocket endpoints.We should add support for the REST API mode while keeping the existing JSON-RPC mode working, giving users flexibility to choose based on their deployment scenario.
Current situation:
bbernhard/signal-cli-rest-apiCurrent problems with containerized deployments:
Non-existent endpoints: Gateway tries to connect to
/api/v1/events(SSE) and/api/v1/rpc(JSON-RPC), but the REST API image only exposes/v1/receive/{account}(WebSocket) and/v2/send(REST)Connection failures: Logs show repeated 404 errors:
Workarounds required: Users must choose between:
Missing "Note to Self" support: The current implementation doesn't properly handle
syncMessage.sentMessage(self-messages), which is needed for the "Note to Self" workflow where users talk to the bot via their own Signal accountProposed solution
Add REST API support alongside existing JSON-RPC, with automatic detection and manual configuration:
Configuration Option
Add
apiModesetting to Signal channel config:{ "channels": { "signal": { "httpUrl": "http://localhost:8080", "account": "+1234567890", "apiMode": "auto" // "auto" (default), "jsonrpc", or "rest" } } }API Mode Options:
"auto"(default) - Auto-detect by probing available endpoints (try REST first, fall back to JSON-RPC)"jsonrpc"- Force legacy JSON-RPC + SSE mode (for direct signal-cli installations)"rest"- Force REST + WebSocket mode (for bbernhard/signal-cli-rest-api containers)Dual Implementation
Support both API modes in parallel:
JSON-RPC Mode (existing, keep as-is)
Receiving messages:
GET /api/v1/events(SSE)Sending messages:
POST /api/v1/rpcwith methodsendTyping indicators:
POST /api/v1/rpcwith methodsendTypingRead receipts:
POST /api/v1/rpcwith methodsendReceiptHealth checks:
GET /api/v1/checkREST Mode (new)
Receiving messages:
GET /v1/receive/{account}(WebSocket)Sending messages:
POST /v2/send(REST)Typing indicators:
PUT /v1/typing-indicator/{phone}(REST)Read receipts:
POST /v1/receipts/{phone}(REST)Health checks:
GET /v1/aboutEvent Format Handling
The event handler should support both formats:
SSE format (JSON-RPC mode):
{ "event": "receive", "data": "{\"envelope\":{\"sourceNumber\":\"+1234567890\",\"dataMessage\":{\"message\":\"Hello\"}}}" }WebSocket format (REST mode):
{ "envelope": { "sourceNumber": "+1234567890", "dataMessage": { "message": "Hello" } } }Critical syncMessage Handling (both modes)
Both modes should properly handle syncMessage to support "Note to Self":
Benefits
bbernhard/signal-cli-rest-api:latestDocker imagedocker-compose upjust works for containerized setupsUse Cases
Use Case 1: Existing host-based deployment (JSON-RPC)
{ "channels": { "signal": { "httpUrl": "http://localhost:8080", "account": "+1234567890", "apiMode": "jsonrpc" // or "auto" (will detect JSON-RPC) } } }Use Case 2: New containerized deployment (REST)
Use Case 3: Mixed environment
{ "channels": { "signal": { "accounts": { "personal": { "httpUrl": "http://localhost:8080", "account": "+1234567890", "apiMode": "jsonrpc" }, "work": { "httpUrl": "http://signal-container:8080", "account": "+9876543210", "apiMode": "rest" } } } } }Alternatives considered
Implementation plan
apiModeconfiguration option toSignalAccountConfigdetectSignalApiMode()to probe available endpointssignalRestRequest,streamSignalEvents)Additional context
Note: The implementation should maintain full backward compatibility. Existing configurations without
apiModespecified should continue to work via auto-detection.