Signal Messenger using received message

I’m trying to use the Signal Integration and trigger automations with received messages.

Home Assistant should receive messages and switch either a red lightbulb or a green lightbulb on, depending on the message text. It should also reply with “OK, green” or “OK, red”.

I started with the standard REST sensor from the docs:

- resource: "http://127.0.0.1:8080/v1/receive/<number>"
  headers:
    Content-Type: application/json
  sensor:
    - name: "Signal message received"
      value_template: "" #this will fetch the message
      json_attributes_path: $[0].envelope
      json_attributes:
        - source #using attributes you can get additional information, in this case, the phone number.

But this gives me chatty messages every few seconds in my log (JSON result was not a dictionary or list with 0th element a dictionary), which I’d rather avoid because it pollutes the log when looking for real errors.

Is there a standard way to avoid those log messages?

I’ve tried this to get rid of the warnings by configuring the sensor like this:

resource: "http://127.0.0.1:8080/v1/receive/<number>"
headers:
  Content-Type: application/json
sensor:
  - name: "Signal message sender"
    value_template: "{{ value_json[0]['envelope']['sourceNumber'] if value_json else '' }}"
  - name: "Signal message received"
    value_template: "{{ value_json[0]['envelope']['dataMessage']['message'] if value_json else '' }}"

This seems to get rid of the warning and gives me two entities for the message text and the message sender.

However, when testing I can see that when receiving a message sometimes only one of the sensors is set and the other remains at unknown.
Can anyone explain what’s going on there?

It may have to do with the message being re-set to unknown before the sender?

The automation for turning on the green light looks like this. It tries to make sure that HA only reacts when my numbers sends the command.

triggers:
  - trigger: template
    value_template: >-
      {{ is_state('sensor.signal_message_sender', '+44444444444') and
      (states('sensor.signal_message_received') | lower == 'green')
      }}
actions:
  - action: notify.signal
    data:
      message: OK, green

Is that a viable way of implementing this automation?

the signal instructions seem to be wrong according to this thread: Unable to receive messages using Signal Messenger integration - #4 by Lewis

same here: Signal Messanger - can't recieve messages Websockets support needed. · Issue #117555 · home-assistant/core · GitHub

this might be a way around: GitHub - kalbasit/signal-api-receiver

this solution worked for me:

  1. install signal - make sure sending works, I changed the port to 9123
  2. change signal configuration to “json-rpc” and start addon
  3. test in terminal / ssh with :
curl http://192.168.1.77:9123/v1/about

should return:

{"versions":["v1","v2"],"build":2,"mode":"json-rpc","version":"0.92","capabilities":{"v2/send":["quotes","mentions"]}}#   

  1. in the following this fixed homeassistant IP is assumed: 192.168.1.66
  2. install : GitHub - hassio-addons/addon-appdaemon: AppDaemon4 - Home Assistant Community Add-ons
  3. in appdaemon configuration: grey box “python packages” write “websockets” and press “save”
  4. goto appdaemon folder: addon_configs/a0d7b954_appdaemon/apps
  5. change file : apps.yaml to (keep “—”)
 ---
hello_world:
  module: hello
  class: HelloWorld
signal_receiver:
  module: signal_listener
  class: SignalReceiver
  signal_ws_uri: "ws://192.168.1.66:YOUR_SIGNALPORT/v1/receive/+49YOURPHONENUMBERHERE"   

in my case it looks like this: :wink:

---
hello_world:
  module: hello
  class: HelloWorld
signal_receiver:
  module: signal_listener
  class: SignalReceiver
  signal_ws_uri: "ws://192.168.1.66:9123/v1/receive/+49190666666"   
  1. in appdaemon folder: addon_configs/a0d7b954_appdaemon/apps create new file " signal_listener.py" with content:
#!/usr/bin/python3 
import appdaemon.plugins.hass.hassapi as hass
import websockets
import asyncio
import json

class SignalReceiver(hass.Hass):
    async def initialize(self):
        self.uri = self.args.get("signal_ws_uri", "ws://localhost:8080/ws")
        self.log(f"Starting Signal WebSocket listener to {self.uri}")
        asyncio.create_task(self.listen_to_signal())

    async def listen_to_signal(self):
        while True:
            try:
                async with websockets.connect(self.uri) as websocket:
                    self.log("Connected to Signal WebSocket")
                    while True:
                        message = await websocket.recv()
                        self.log(f"Received Signal message: {message}")
                        
                        try:
                            data = json.loads(message)
                        except json.JSONDecodeError:
                            self.log("Warning: Received non-JSON message", level="WARNING")
                            data = {"raw_message": message}

                        # Fire event with parsed data
                        self.fire_event("signal_message_received", **data)
            except (websockets.exceptions.ConnectionClosedError, ConnectionRefusedError) as e:
                self.log(f"WebSocket connection error: {e}", level="ERROR")
                self.log("Retrying connection in 5 seconds...")
                await asyncio.sleep(5)
            except Exception as e:
                self.log(f"Unexpected error: {e}", level="ERROR")
                self.log("Retrying connection in 5 seconds...")
                await asyncio.sleep(5)
  1. create new automation:
alias: receive Signal Messages
description: "solution via app deamon and addon_configs/a0d7b954_appdaemon/apps"
triggers:
  - event_type: signal_message_received
    trigger: event
actions:

  - action: notify.signal
    data:
      message: "Home assistant received Signal message from {{ trigger.event.data.envelope.source }}:{{ trigger.event.data.envelope.dataMessage.message }}"
  1. restart HA!
    send signal message from your phone and enjoy!
2 Likes

Thanks for taking the time to describe your setup (and sorry for taking a while to try this out)! This seems to process messages a lot quicker than the standard setup :smile:

It seems that the automation is triggered 3 times with this setup - is this normal?

This is really neat. Two questions: one about user experience and one about implementation.

  1. Is anyone using this idea to broadcast TTS person-to-person social messages either throughout the house or to the room of the recipient when a message is received? Is it too intrusive? Wondering if this could be integrated with a local LLM so that the voice assistant could summarize, relay, and send messages hands free.
  2. Is there a container solution that does not require a HA add-on that can run alongside a docker HA setup?

I think this might be related to typing indicators. Typing indicators also send an envelope via JSON, but there’s no message attached. In my case, it looks like things work correctly every time as long as I wait for the typing indicator to be consumed by HA before I send my message. E.g., type the message, wait 15 seconds, then send. I think the issue arises when a signal-cli receive command gets two different envelopes in a single receive operation, one of which contains the typing indicator and one of which has the message. I think, in that case, only the first is envelope is processed, maybe?

If that’s the case, a potential solution would be to disable typing indicators for the Signal account used by HA. However, it’s not immediately clear to me how to do this via the Signal CLI REST API. I know it’s possible via the Signal CLI app using the updateConfiguration command, but I’m not sure if that’s exposed via the REST API?

Edit: While I suspect that turning off the typing notification would resolve the issue, for now I have had luck with adjusting the listener so that it uses index -1 (i.e., the last envelope) instead of index 0 (the first envelope). Since the typing indicator should always be prior to the message, I think this should work? So, it looks like this now:

resource: "http://127.0.0.1:8080/v1/receive/<number>"
headers:
  Content-Type: application/json
sensor:
  - name: "Signal message sender"
    value_template: "{{ value_json[-1]['envelope']['sourceNumber'] if value_json else '' }}"
  - name: "Signal message received"
    value_template: "{{ value_json[-1]['envelope']['dataMessage']['message'] if value_json else '' }}"

a little bit late.
yes its probably the envelopes. if your number receives multiple messages. between queing the rest api you get multiple envelopes. either you handle all envelopes is some way or what i used to do is you can specify how much messages get “read” in the rest query with max_messages.
eg.

http://127.0.0.1:8080/v1/receive/<number>?max_messages=1

i switched to a custom_integration which uses the websocket api.
https://github.com/mattleh/signal_websocket which i stitched together with Gemini

1 Like

Signal AI Chat Bot with AppDaemon + Claude (Anthropic) — Full Guide

Hey everyone,

I wanted to share my setup for a Signal AI Chat Bot that lets me chat with Home Assistant through Signal Messenger, powered by Claude (Anthropic) as the conversation agent. It can answer questions about my home AND control devices — lights, heating, blinds, you name it.

Architecture Overview

The setup uses three components that are already available as HA add-ons:

  1. Signal Messenger REST API add-on (bbernhard) — exposes a REST + WebSocket API on port 8080
  2. AppDaemon add-on — runs a custom Python app that bridges Signal and HA
  3. Anthropic (Claude) integration — configured as a conversation agent with Assist API enabled

The flow is simple:
Signal message → WebSocket → AppDaemon → HA Conversation API (Claude) → Signal reply

Prerequisites

  • Signal Messenger REST API add-on installed and registered with a phone number (I used a landline number — Signal supports voice verification for those)
  • AppDaemon add-on installed, with websockets and aiohttp added to the Python packages in the add-on configuration
  • Anthropic integration set up with a conversation agent (or any other conversation agent — Google Generative AI works too, just change the agent_id)
  • A long-lived access token for HA (Profile → Security → Long-Lived Access Tokens)

Key Features

  • Multi-turn conversations — conversation ID is stored per sender, so context is preserved across messages
  • Group message support — works in Signal groups, not just 1:1 chats
  • Allowed sender filtering — only whitelisted numbers get AI responses
  • Markdown stripping — Claude loves markdown, Signal doesn’t render it, so we strip it
  • Auto-reconnect — if the WebSocket drops, it reconnects after 10 seconds
  • HA event firing — every incoming message fires a signal_message_received event, so you can build additional automations on top

The Tricky Part: Group Messages

This one cost me some time. The Signal REST API WebSocket delivers group messages with an internal_id as the groupId. But the /v2/send endpoint expects the id field (which looks different and has a group. prefix). The solution: on startup, the app loads all groups via /v1/groups/NUMBER and builds a mapping from internal_id to id. Problem solved.

Setup

1. Add Python packages to AppDaemon

In the AppDaemon add-on configuration, add to “Python packages”:

websockets
aiohttp

2. Create the app file

Place this as /addon_configs/a0d7b954_appdaemon/apps/signal_listener.py:

import appdaemon.plugins.hass.hassapi as hass
import asyncio
import json
import re
import aiohttp

try:
    import websockets
except ImportError:
    websockets = None


def strip_markdown(text):
    """Convert markdown to plain text for Signal."""
    text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
    text = re.sub(r'__(.+?)__', r'\1', text)
    text = re.sub(r'\*(.+?)\*', r'\1', text)
    text = re.sub(r'(?<!\w)_(.+?)_(?!\w)', r'\1', text)
    text = re.sub(r'`(.+?)`', r'\1', text)
    text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
    text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
    text = re.sub(r'^[\-\*]\s+', '• ', text, flags=re.MULTILINE)
    return text.strip()


class SignalReceiver(hass.Hass):
    async def initialize(self):
        if websockets is None:
            self.log("websockets package not installed!", level="ERROR")
            return

        self.uri = self.args.get("signal_ws_uri", "ws://HA_IP:8080/v1/receive/+YOUR_NUMBER")
        self.own_number = self.args.get("own_number", "+YOUR_NUMBER")
        self.signal_api = self.args.get("signal_api", "http://HA_IP:8080")
        self.ha_url = self.args.get("ha_url", "http://supervisor/core")
        self.ha_token = self.args.get("ha_token", "")
        self.allowed_numbers = self.args.get("allowed_numbers", [])
        self.conversations = {}
        self.group_map = {}
        self.log(f"Starting Signal AI Bot on {self.uri}")
        self.log(f"Allowed senders: {self.allowed_numbers or 'ALL'}")
        await self.load_group_map()
        self.create_task(self.listen_to_signal())

    async def load_group_map(self):
        url = f"{self.signal_api}/v1/groups/{self.own_number}"
        try:
            async with aiohttp.ClientSession() as session:
                async with session.get(url) as resp:
                    if resp.status == 200:
                        groups = await resp.json()
                        for g in groups:
                            internal_id = g.get("internal_id", "")
                            api_id = g.get("id", "")
                            if internal_id and api_id:
                                self.group_map[internal_id] = api_id
                        self.log(f"Loaded {len(self.group_map)} group mappings")
        except Exception as e:
            self.log(f"Error loading groups: {e}", level="WARNING")

    async def listen_to_signal(self):
        while True:
            try:
                async with websockets.connect(self.uri) as ws:
                    self.log("Connected to Signal WebSocket")
                    while True:
                        raw = await ws.recv()
                        try:
                            data = json.loads(raw)
                        except json.JSONDecodeError:
                            continue
                        envelope = data.get("envelope", {})
                        source = envelope.get("sourceNumber") or envelope.get("source", "")
                        data_msg = envelope.get("dataMessage", {})
                        message = data_msg.get("message", "")
                        group_info = data_msg.get("groupInfo", {})
                        group_id = group_info.get("groupId", "")
                        if source == self.own_number or not message:
                            continue
                        if self.allowed_numbers and source not in self.allowed_numbers:
                            continue
                        self.fire_event("signal_message_received", source=source, message=message, group_id=group_id)
                        try:
                            response = await self.process_with_ai(source, message)
                            if response:
                                await self.send_signal_reply(source, response, group_id)
                        except Exception as e:
                            self.log(f"AI error: {e}", level="ERROR")
            except Exception as e:
                self.log(f"WebSocket error: {e}", level="ERROR")
                await asyncio.sleep(10)

    async def process_with_ai(self, source, message):
        conv_id = self.conversations.get(source)
        payload = {"text": message, "agent_id": "conversation.claude_conversation", "language": "de"}
        if conv_id:
            payload["conversation_id"] = conv_id
        headers = {"Authorization": f"Bearer {self.ha_token}", "Content-Type": "application/json"}
        try:
            async with aiohttp.ClientSession() as session:
                async with session.post(f"{self.ha_url}/api/conversation/process", json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=30)) as resp:
                    if resp.status == 200:
                        result = await resp.json()
                        new_conv_id = result.get("conversation_id")
                        if new_conv_id:
                            self.conversations[source] = new_conv_id
                        speech = result.get("response", {}).get("speech", {}).get("plain", {}).get("speech", "")
                        return strip_markdown(speech)
                    else:
                        body = await resp.text()
                        self.log(f"API failed ({resp.status}): {body}", level="ERROR")
                        return None
        except Exception as e:
            self.log(f"HA API error: {e}", level="ERROR")
            return None

    async def send_signal_reply(self, source, text, group_id=""):
        url = f"{self.signal_api}/v2/send"
        if group_id:
            api_id = self.group_map.get(group_id)
            if not api_id:
                await self.load_group_map()
                api_id = self.group_map.get(group_id)
            if not api_id:
                self.log(f"Unknown group: {group_id}", level="ERROR")
                return
            payload = {"message": text, "number": self.own_number, "recipients": [api_id]}
        else:
            payload = {"message": text, "number": self.own_number, "recipients": [source]}
        try:
            async with aiohttp.ClientSession() as session:
                async with session.post(url, json=payload) as resp:
                    if resp.status not in (200, 201):
                        body = await resp.text()
                        self.log(f"Send failed ({resp.status}): {body}", level="ERROR")
        except Exception as e:
            self.log(f"Send error: {e}", level="ERROR")

3. Configure the app

Add to /addon_configs/a0d7b954_appdaemon/apps/apps.yaml:

signal_receiver:
  module: signal_listener
  class: SignalReceiver
  signal_ws_uri: "ws://YOUR_HA_IP:8080/v1/receive/+YOUR_SIGNAL_NUMBER"
  own_number: "+YOUR_SIGNAL_NUMBER"
  signal_api: "http://YOUR_HA_IP:8080"
  ha_url: "http://supervisor/core"
  ha_token: "YOUR_LONG_LIVED_ACCESS_TOKEN"
  allowed_numbers:
    - "+YOUR_PHONE_NUMBER"

4. Restart AppDaemon and check the logs.

Swapping the AI Agent

The agent_id in process_with_ai can point to any HA conversation agent. Want to use Google Generative AI instead? Just change it to conversation.google_generative_ai. Want the built-in HA Assist? Use conversation.home_assistant. The rest of the code stays the same.

Bonus: HA Events for Automations

Every incoming message fires a signal_message_received event with source, message, and group_id. You can use this to trigger automations — for example, if someone texts “alarm” to the group, you could arm your alarm system regardless of the AI response.

Tips

  • Set allowed_numbers to restrict who can talk to your bot. Leave the list empty to allow everyone (not recommended).
  • The conversation ID is stored in memory per sender. If AppDaemon restarts, conversation context resets — this is fine in practice since Claude handles standalone questions well.
  • If you are running HA OS, http://supervisor/core works as the HA URL from within add-ons. For other setups, use your actual HA URL.

Hope this helps someone. Happy to answer questions!