Signal Bot

Connect your AI agent to Signal via a local signal-cli-rest-api bridge — end-to-end encrypted, no cloud tokens

Connect your AI agent to Signal — end-to-end encrypted, linked through a local signal-cli-rest-api bridge, no cloud bot token needed.

python
from praisonaiagents import Agent
from praisonai_bot.bots import SignalBot

agent = Agent(name="assistant", instructions="Be helpful on Signal.", llm="gpt-4o-mini")
bot = SignalBot(account="+15551234567", bridge_url="http://localhost:8080", agent=agent)

import asyncio
asyncio.run(bot.start())

The user messages you on Signal; the bridge relays the chat to the agent and the agent replies in the same thread.

Diagram

📱 Signal User

🔒 Signal Network

📡 signal-cli-rest-api

🤖 SignalBot

🧠 AI Agent

Quick Start

  1. One command (CLI)

    The quickest way to run Signal — no Python needed:

    bash
    praisonai bot run --platform signal \
        --account +15551234567 \
        --bridge-url http://bridge:8080 \
        --agent agents.yaml

    The --account / --bridge-url flags are generated from Signal’s ChannelDescriptor.config_fields. bot run starts any registered platform by name. See praisonai bot run for the full flag list.

  2. Run the bridge

    Signal links a device through the signal-cli-rest-api bridge. Start it with Docker in native mode.

    bash
    docker run -d --name signal-api -p 8080:8080 \
      -v ./signal-data:/home/.local/share/signal-cli \
      -e MODE=native bbernhard/signal-cli-rest-api
  3. Link your device

    Open the bridge link URL, then scan the QR from Signal → Settings → Linked Devices → Link New Device.

    http://localhost:8080/v1/qrcodelink?device_name=praisonai
  4. Start the bot

    Point the bot at your linked account and the bridge.

    python
    from praisonaiagents import Agent
    from praisonai_bot.bots import SignalBot
    
    agent = Agent(name="assistant", instructions="Be helpful on Signal.", llm="gpt-4o-mini")
    bot = SignalBot(account="+15551234567", bridge_url="http://localhost:8080", agent=agent)
    
    import asyncio
    asyncio.run(bot.start())

    Message your linked Signal number and the agent replies.


How It Works

Signal is poll-based: the bot fetches inbound envelopes from the bridge, routes text to the agent, and sends the reply back through the bridge.

Diagram
sequenceDiagram
    participant User as 📱 Signal User
    participant Bridge as 📡 Bridge
    participant Bot as 🤖 SignalBot
    participant Agent as 🧠 Agent

    User->>Bridge: Send message
    loop Every poll_interval
        Bot->>Bridge: GET /v1/receive/{account}
        Bridge-->>Bot: Envelopes (DMs + groups)
    end
    Bot->>Agent: chat(content)
    Agent-->>Bot: Response
    Bot->>Bridge: POST /v2/send
    Bridge-->>User: Reply

    classDef user fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef process fill:#189AB4,stroke:#7C90A0,color:#fff
    classDef agent fill:#10B981,stroke:#7C90A0,color:#fff

    class User user
    class Bridge,Bot process
    class Agent agent

The adapter talks HTTP/JSON to these bridge endpoints:

EndpointPurpose
GET /v1/receive/{account}Poll inbound envelopes (DMs + groups)
POST /v2/sendSend text
PUT /v1/typing-indicator/{account}Typing indicator (best-effort)
GET /v1/accountsBridge reachability + linkage check (probe())

Inbound liveness. A delivered envelope refreshes the Signal channel’s last_activity for the shared Channel Supervision health monitor. Successfully-empty polls do not refresh it, so a silently half-open bridge (returns [] forever without raising) trips stale-socket after stale_after seconds and the supervisor reconnects — instead of the bot going quietly deaf.


Configuration

Signal needs an account and a bridge URL. Configure it with YAML, environment variables, or the Python SDK.

YAML

yaml
# bot.yaml
channels:
  signal:
    account: "+15551234567"
    bridge_url: "http://localhost:8080"
    allowed_users: ["+15557654321"]
python
from praisonai_bot.bots import BotOS

BotOS(agent=agent, platforms=["telegram", "signal"]).start()

Environment

bash
export SIGNAL_ACCOUNT="+15551234567"
export SIGNAL_BRIDGE_URL="http://localhost:8080"
python
from praisonaiagents import Agent
from praisonai_bot.bots import SignalBot

agent = Agent(name="assistant", instructions="Be helpful.", llm="gpt-4o-mini")
bot = SignalBot(agent=agent)  # reads SIGNAL_ACCOUNT / SIGNAL_BRIDGE_URL

import asyncio
asyncio.run(bot.start())

Python SDK

python
from praisonaiagents import Agent
from praisonai_bot.bots import SignalBot

agent = Agent(name="assistant", instructions="Be helpful.", llm="gpt-4o-mini")

bot = SignalBot(
    account="+15551234567",
    bridge_url="http://localhost:8080",
    agent=agent,
    allowed_users=["+15557654321"],
    poll_interval=1.0,
)

import asyncio
asyncio.run(bot.start())

Config keys

FieldEnv varRequiredDefaultDescription
accountSIGNAL_ACCOUNT✅—Linked Signal phone number, e.g. +15551234567
bridge_urlSIGNAL_BRIDGE_URL❌http://localhost:8080Base URL of the running signal-cli-rest-api bridge

Constructor parameters

ParameterTypeDefaultDescription
tokenstr""Accepted for adapter-wiring parity; unused (Signal is token-free)
accountstr"" → SIGNAL_ACCOUNTLinked Signal number. Required at start(); raises ValueError if empty
bridge_urlstr"" → env → http://localhost:8080Bridge base URL; trailing slash stripped
agentOptional[Agent]NoneThe PraisonAI agent to route messages to
configOptional[BotConfig]BotConfig(token="", mode="polling")Shared bot config
allowed_usersOptional[List[str]]NoneDM allowlist. Numbers normalise to digits so +1 555… and 1555… match
poll_intervalfloat1.0Seconds between /v1/receive polls; clamped to a minimum of 0.2

Capabilities & Limits

Signal declares honest capabilities so shared engines degrade correctly.

CapabilityValue
max_message_length2000
supports_editFalse (streaming falls back to chunked sends)
supports_typingTrue
needs_rate_limitTrue
accepts_webhooksFalse (poll-based)
supports_mediaFalse (text-only replies; inbound voice notes are transcribed — see Inbound voice notes)

Built-in Commands

Every Signal channel ships these commands out of the box.

CommandDescription
/statusShow bot status and info
/newReset conversation session
/helpShow help + custom commands
/stopCancel the current agent run

Register custom commands:

python
@bot.on_command("ping")
async def ping(msg):
    return "Pong!"

DM Allowlist

Restrict who can DM the bot with allowed_users — phone numbers are normalised to digits so formatting never blocks a match.

python
bot = SignalBot(
    account="+15551234567",
    agent=agent,
    allowed_users=["+1 555 765 4321", "15559876543"],
)

Groups pass through the allowlist — group membership is the gate there, not the DM list.


What’s Not Supported Yet

This release ships text messaging plus inbound voice-note transcription. The following are intentionally not wired:

FeatureBehaviour
edit_message()Sends a new message (Signal has no edit)
delete_message()Returns False (bridge doesn’t expose it)
add_reaction() / remove_reaction()Return False (not wired)
Media in/outsupports_media=False for outbound (text only). Inbound audio is transcribed via the bridge — see Voice Notes.
WebhooksPoll-based; accepts_webhooks=False

Troubleshooting

ValueError: Signal account is required

The account is empty. Set it via the constructor, channels.signal.account, or the SIGNAL_ACCOUNT environment variable before calling start().

ImportError: aiohttp is required

The Signal adapter uses aiohttp (lazy-imported). Install it:

bash
pip install aiohttp
No messages arrive

Confirm the bridge is linked and reachable. Check GET /v1/accounts returns your number, and lower poll_interval (minimum 0.2s) for faster pickup. Verify the bridge runs in native or json-rpc mode so /v1/receive returns envelopes.

Bot reports HEALTHY but hasn't received a message in hours

The signal-cli-rest-api bridge can go silently half-open — the HTTP call succeeds but the bridge returns [] forever. The gateway health monitor treats time since the last delivered envelope as the liveness signal (empty polls don’t count), so the channel will trip stale-socket after stale_after seconds (default 120s) and the supervisor will reconnect automatically. Confirm via GET /health that last_activity for the Signal channel is advancing on real inbound messages, and consider lowering stale_after on very-chatty accounts or raising it on quiet ones. See Channel Supervision → Proactive Health Monitoring.

Bridge unreachable on probe

probe() calls GET /v1/accounts. A non-200 means the bridge is down or the URL is wrong. Ensure bridge_url matches the Docker port mapping (default http://localhost:8080).


Best Practices

Keep the bridge private

The signal-cli-rest-api bridge holds your linked device. Run it on localhost or a private network — never expose port 8080 to the public internet.

Use the allowlist for DMs

Set allowed_users so only trusted numbers can start a conversation. Numbers normalise to digits, so any formatting works.

Tune the poll interval

poll_interval trades latency for load. Keep the default 1.0s for most bots; lower it toward 0.2s for snappier replies, raise it to reduce bridge traffic.

Expect plain text

Signal has no message editing and replies are text-only. Design replies as self-contained plain-text messages rather than relying on edits or rich media.


Inbound voice notes

Signal voice notes are transcribed automatically — the bot fetches the audio attachment from the signal-cli-rest-api bridge and the agent receives the transcript as plain text. STT is on by default; tune or opt out with the shared stt: block.

yaml
channels:
  signal:
    platform: signal
    account: ${SIGNAL_ACCOUNT}
    bridge_url: ${SIGNAL_BRIDGE_URL}
    stt:
      enabled: true
      language: en

The DM allowlist runs before the bridge download, so rejected senders never trigger a transcription request. See Voice Notes for the full STT configuration.


Standalone scheduled delivery (no gateway)

You do not need a running Signal Bot process to deliver a scheduled brief to Signal. Point SIGNAL_ACCOUNT at your linked sender number (and, if the bridge is not on http://localhost:8080, set SIGNAL_BRIDGE_URL). From OS cron / CI, praisonai schedule tick will POST to {SIGNAL_BRIDGE_URL}/v2/send using an explicit deliver: signal:<recipient> target or SIGNAL_HOME_CHANNEL. The bridge must be reachable from wherever the tick fires. See Out-of-process delivery.


Related