Signal channel¶
Talk to an agent over Signal, from your own number, on your own machine or infrastructure. VegaDūta does not run Signal for you: you run signal-cli-rest-api (a REST and websocket wrapper around signal-cli), register or link a number with it, and connect the console to it. Nothing about your messages passes through a third-party bot platform.
How it fits together¶
Signal <-> signal-cli-rest-api (yours) <-> VegaDūta core -> agent
state: signal-cli-config volume (pulls messages, sends replies)
- Inbound is pulled. Signal has no push to a public URL, so core keeps a receiver per
connected number. It follows signal-cli's mode, read from
GET /v1/about: long pollingGET /v1/receive/{number}fornormalandnative, the receive websocket forjson-rpc. A dropped connection backs off (1 s up to 60 s) and reconnects by itself. - Outbound goes through the approval gate like every other channel: a reply is an
channel.signal.sendaction, so approval policies, the audit log and the send quota apply. Long replies are split at paragraph boundaries (app.signal.max-message-chars). - Secrets. Signal has no platform key. The only optional secret is a bearer token for a reverse proxy in front of signal-cli; it is stored encrypted, sent only over https (or to a host you allow-listed), and never returned by the API or written to a log. The base URL may not carry a user name or password, and the API endpoints need the channel-management permission.
Set it up¶
- Run signal-cli-rest-api. With Docker Compose next to the Lite stack:
docker compose -f deploy/compose/lite.yml -f deploy/compose/signal.yml \
--env-file deploy/compose/.env --profile signal up -d
Link an existing phone (QR code) or register a number you control; the steps are at the top of
deploy/compose/signal.yml. The container publishes no port: the API has no authentication.
2. Let core reach it. Private addresses are refused by default, so list the host (host:port):
APP_SECURITY_OUTBOUND_ALLOWED_PRIVATE_HOSTS=signal-cli:8080
This is the same mechanism a self-hosted Ollama or MCP server uses. A signal-cli on a public
https:// URL needs no entry.
3. In the console open Channels > Signal, enter the base URL (http://signal-cli:8080) and the
registered number (+4915112345678). Connecting checks that the server answers and has an
account for that number.
4. Choose the agent that answers (Route inbound to).
Who the agent answers (default deny)¶
The agent never answers someone you have not approved.
- A first-time sender gets one reply with a six-digit pairing code. The conversation appears under pending pairings (Channels, or the inbox); you approve or reject it there, exactly as for Telegram. Until then further messages are ignored. Add the number as a contact, or switch the channel to auto-approve in the routing settings, to skip the step.
- Unknown senders = IGNORE (setting): strangers get no reply and nothing is stored, unless they are already a session or a contact. Use it when the number is public.
- Groups are ignored by default. Set Groups to
MENTION(answer only when the agent is mentioned or one of its messages is quoted) orALL. A group is its own conversation that you approve once; everyone in an approved group can then talk to the agent. - A person is keyed by their number, or by their Signal UUID when they hide their number, so the same person keeps one memory. If someone later hides their number they appear as a new sender.
- Receipts, typing notices, reactions, deletions, stories and your own other devices' messages are
dropped. An attachment is described to the agent (
[attachment: image/png name]), not downloaded.
Configuration¶
| Property | Default | Meaning |
|---|---|---|
app.signal.receive.enabled |
true |
Run the receiver. It is idle until a Signal connection exists. false removes it from this process. |
app.signal.receive.mode |
auto |
auto reads /v1/about; poll or websocket forces one. |
app.signal.receive.poll-seconds |
15 |
Long-poll wait per request in poll mode (1 to 25). |
app.signal.max-message-chars |
3500 |
A reply longer than this is split into several messages. |
app.signal.max-attachment-bytes |
20971520 |
Largest file the agent may send. |
app.security.outbound.allowed-private-hosts |
empty | Must list a private signal-cli (host:port). |
Run the receiver in ONE core process per number: signal-cli hands each message out once, so two
receivers would split a number's messages between them. The jobs and worker deployables have
the receiver switched off already; with several API replicas, set
APP_SIGNAL_RECEIVE_ENABLED=false on all but one.
API¶
| Method and path | Purpose |
|---|---|
POST /api/channels/signal/credential |
Connect: baseUrl, number, optional authToken, unknownSenders (PAIR/IGNORE), groupMode (IGNORE/MENTION/ALL). |
GET /api/channels/signal/credential |
Current connection (never the token), 204 if none. |
PUT /api/channels/signal/credential/policy |
Change unknownSenders / groupMode without reconnecting. |
DELETE /api/channels/signal/credential |
Disconnect (leaves the signal-cli registration alone). |
POST /api/channels/signal/send |
Send through the approval gate; to is a number, a UUID or group:<id>. |
PUT /api/channels/signal/default-agent |
Which agent answers new conversations. |
Limits and notes¶
- No workflow trigger binding for Signal yet (Telegram, WhatsApp and SMS have one).
- Messages that arrive while core is down wait in signal-cli and are delivered when the receiver reconnects.
- A turn runs on the receiver's thread, so a slow model delays other Signal chats of that number.
- The websocket path validates the URL just before it connects; unlike the REST calls it cannot pin
the resolved address, so a DNS answer that changes between check and connect is not caught.
Listing the host in
allowed-private-hosts(the usual case) avoids the question. - Not covered by a test against a real signal-cli: the tests use a fake server that speaks the documented endpoints.