Node-RED

A private AI assistant over Signal

A walkthrough of building a private AI assistant you can text over Signal - with conversation memory, an emoji-driven context reset, a sender allowlist, and a choice between local Ollama inference and a cloud API.

The idea

The goal: an AI assistant you can text. Not through a cloud chatbot, not through yet another app, but through Signal - end-to-end encrypted, reachable from your phone the same way any contact is.

The pieces are all there. signal-cli-rest-api wraps the Signal CLI in a REST and WebSocket API. Node-RED can run in a Docker container on the same host. Ollama provides local inference, and Regolo offers an OpenAI-compatible cloud API. The work is wiring them together.

sequenceDiagram participant U as User (Signal app) participant S as signal-cli-rest-api participant N as Node-RED participant R as Regolo API U->>S: Signal message S->>N: WebSocket envelope (JSON) N->>N: Allow-list check (UUID) alt Reaction emoji N->>N: Clear sender context else Text message N->>N: Build context (15-min TTL, 8 max) N->>S: PUT typing indicator N->>R: POST /v1/chat/completions R-->>N: Assistant reply N->>N: Strip HTML, store in context N->>S: POST /v2/send S-->>U: Signal reply end

signal-cli-rest-api: the bridge

signal-cli-rest-api runs as a Docker container (bbernhard/signal-cli-rest-api) in json-rpc mode. Before anything else, you link a real Signal account to it - a dedicated number that becomes the bot’s identity. Once linked, the container exposes two key endpoints: a WebSocket receive path that streams incoming messages, and a REST send path for outgoing replies.

The WebSocket endpoint is the interesting one. It long-polls Signal’s servers with a configurable timeout - in this case, five seconds - and pushes any received envelopes down the WebSocket as JSON. Each envelope contains the sender’s UUID, the message body, a timestamp, and any reactions (emoji responses to previous messages).

The receive path

In Node-RED, a websocket-client node connects to ws://signal_cli:8080/v1/receive/<bot-number>?timeout=5. Every time someone texts the bot, a JSON envelope lands in the flow. The first thing the flow does is check who sent it.

Who gets to talk

The allowlist is a function node with a hardcoded array of Signal UUIDs - not phone numbers. Signal identifies users by UUID internally; sourceUuid is stable and doesn’t change if someone changes their number. The list might contain a handful of UUIDs - your own, a colleague’s, anyone you want to grant access. If the sender isn’t on the list, the message is dropped silently. No error reply, no acknowledgement - the bot simply doesn’t exist for them.

// Allow-list of senders
let validSignalUsers = [
  "<your-signal-uuid>",       // Me main
  "<colleague-uuid-1>",       // Me work
  // ...
];

if (validSignalUsers.includes(msg.payload.envelope.sourceUuid)) {
  msg.sender = msg.payload.envelope.sourceUuid;
  return msg;
} else {
  node.error(`Sender ${msg.payload.envelope.sourceUuid} not allowed`);
  return null;
}

The emoji reaction router

This is where it gets fun. Signal supports emoji reactions - you can react to a message with any emoji. The flow checks whether an incoming envelope contains a reaction. If it does, it checks whether the emoji is 🤯 (the exploding head).

If it is, the flow clears that sender’s conversation history. All of it. Gone. The next message starts a fresh conversation. This is a surprisingly natural UX: you don’t need to type a command like /reset - you just react to the bot’s last message with the exploding head emoji and the context is wiped.

If the reaction is any other emoji, it’s ignored. And if there’s no reaction at all - just a regular text message - the flow continues to the main processing path.

Conversation memory

Each sender gets their own conversation history, stored in Node-RED’s global context under a key called SIGNAL_AI_ASSISTANT. The structure is a map of sender UUID to an array of messages, each with a content string and a timestamp.

Two limits keep things bounded. Messages older than 15 minutes are evicted - if you haven’t talked to the bot in a quarter of an hour, it starts fresh. And the array is capped at 8 messages total, so even a fast back-and-forth won’t grow unbounded.

When a new message arrives, the flow builds an XML-tagged context block for the LLM. Previous messages are wrapped in <context_message> tags inside a <context> block. The new message is wrapped in a <latest_message> tag. This separation matters: the LLM is told explicitly that the context block is background only - use it for consistency and memory - while the latest message is the one to answer. This prevents the model from re-answering old questions or getting confused about which message is current.

After the LLM replies, the assistant’s response is also stored in the context array, so the next exchange has the full back-and-forth.

The context-building function enforces both limits and assembles the XML-tagged prompt:

const MAX_CONTEXT_AGE_MS = 15 * 60 * 1000; // 15 minutes
const MAX_MESSAGES = 8;

let ctx = global.get('SIGNAL_AI_ASSISTANT') || {};
const sender = msg.sender;
let messages = (ctx[sender] && Array.isArray(ctx[sender].messages))
  ? [...ctx[sender].messages] : [];

// Drop messages older than 15 minutes
messages = messages.filter(m =>
  (newMessageTimestamp - (m.timestamp || 0)) <= MAX_CONTEXT_AGE_MS);

// Cap at MAX_MESSAGES - 1 before adding the new one
while (messages.length > MAX_MESSAGES - 1) messages.shift();

messages.push({ content: String(newMessageContent), timestamp: newMessageTimestamp });

ctx[sender] = { messages };
global.set('SIGNAL_AI_ASSISTANT', ctx);

// Build XML-tagged prompt: context block + latest message
const previous = messages.slice(0, -1);
const latest   = messages[messages.length - 1];

const contextBlock = previous.map(m =>
  `<context_message ts="${m.timestamp}">${m.content}</context_message>`).join('\n');

const merged = (previous.length > 0)
  ? `<context (oldest->newest)>\n${contextBlock}\n</context>\n\n` +
    `<latest_message ts="${latest.timestamp}">${latest.content}</latest_message>`
  : `<latest_message ts="${latest.timestamp}">${latest.content}</latest_message>`;

msg.context = merged;
return msg;

Two ways to query

The flow defines two subflows for talking to an LLM, but only one is wired in.

In this example, the active subflow, Query Regolo, calls an OpenAI-compatible API at api.regolo.ai with the model mistral-small-4-119b at temperature 0.3. Regolo is a cloud API - fast, capable, but your messages leave your network. The subflow sends the system prompt and context block as a chat completion request and extracts the assistant’s reply from the response.

The other subflow, Query Ollama with Tools, is defined but not wired up - I’ve included it as an example (and it was the original flow I used to use). It talks to a local Ollama instance running an LLM of your choice (in this example, I had been using mistral-nemo:12b-instruct-2407-q4_K_M)

  • everything stays on the host, no data leaves the network.

But the interesting part is the “with Tools” bit: the subflow’s system prompt instructs the model to use web search tools to improve its answers and to cite the URLs it found. The prompt is detailed - rules about when to use context versus the latest message, how to handle code requests, how to handle decisions, and a requirement to search the web when it would help.

This subflow isn’t wired in by default. The idea is to switch to it once the tool-calling pipeline is solid, so the assistant can look things up in real time instead of only knowing what its training data taught it.

The web search tool

The “Query Ollama with Tools” subflow needs a web search backend. When the LLM emits a tool_call for private_web_search, the subflow builds a command line and hands it to an exec node that runs a small Python script, tools.py. The script queries a SearXNG instance running as a Docker service on the same host, extracts the top result, strips HTML, and prints a single line: the result title and URL followed by a content snippet. That stdout is fed back into the Ollama chat completion as a tool message, so the model can incorporate what it found and cite the URL.

The script is deliberately small - one function, one HTTP GET, one print. It passes a spoofed X-Forwarded-For header to SearXNG (which trusts the reverse proxy in front of it) and returns the first result as title — url on the first line, with the content snippet on the second:

#!/usr/bin/env python3
import argparse, requests, html, re, sys

SEARXNG_URL = "http://searxng:8080/search"  # Docker service name:port

def top_result(query: str, xff: str | None, xri: str | None) -> str:
    params = {"q": query, "format": "json", "language": "en", "safesearch": 0, "categories": "general"}
    headers = {
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                      "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
        "Accept": "application/json,text/plain,*/*",
        "Accept-Language": "en-US,en;q=0.9",
        "Accept-Encoding": "gzip, deflate",
        "Connection": "keep-alive",
        "Referer": "http://searxng:8080/",
        "X-Forwarded-For": "<spoofed-client-ip>"
    }

    r = requests.get(SEARXNG_URL, params=params, headers=headers, timeout=12)
    if r.status_code == 403:
        return r.text
    r.raise_for_status()
    data = r.json()
    results = data.get("results", [])
    if not results:
        return "No result found."
    top = results[0]
    title = (top.get("title") or "").strip()
    url   = (top.get("url") or "").strip()
    content = (top.get("content") or "").strip()
    content = re.sub(r"<[^>]+>", "", html.unescape(content))
    content = re.sub(r"\s+", " ", content).strip()
    piece = " — ".join([p for p in [title, url] if p])
    return f"{piece}\n{content}" if content else (piece or "No result found.")

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--query", required=True)
    ap.add_argument("--xff")
    ap.add_argument("--xri")
    args = ap.parse_args()
    print(top_result(args.query.strip(), args.xff, args.xri))

if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print(f"Error: {e}")
        sys.exit(1)

The --xff and --xri arguments are wired through from the Node-RED flow but not currently used by the SearXNG query - they’re there for future use if the search backend needs a real client IP for rate-limiting or region detection. The exec node calls it as:

/data/tools/venv/bin/python3 /data/tools/tools.py --query "<search terms>"

with a 15-second timeout. If the script exits non-zero or times out, the subflow’s tool-call handler receives empty stdout, which becomes the tool message content - the model is told “No output” and carries on without a citation.

The send path

Before the LLM is even queried, the flow fires off a typing indicator - a PUT to the signal-cli REST API. This makes the bot show “typing…” in Signal while the model is generating. It’s a small touch, but it makes the difference between a bot that feels alive and one that feels like it hung.

Once the LLM responds, the flow strips all HTML tags from the reply. LLMs love to emit markdown and HTML; Signal renders neither. Autolinks like <https://example.com> are unwrapped to plain URLs, then all tags are stripped. The cleaned text is posted to /v2/send with the bot’s number and the sender’s UUID as the recipient.

const unwrapAutoLinks = s =>
  s.replace(/<((?:https?:\/\/|mailto:)[^>]+)>/gi, '$1');

const stripHtmlTags = s =>
  s.replace(/<\/?[a-z][\w:-]*\b[^>]*>/gi, '');

let unwrapped_response = unwrapAutoLinks(msg.payload.assistant);
let final_response = stripHtmlTags(unwrapped_response);

msg.payload = {
  "message": final_response,
  "number": "<bot-number>",
  "recipients": [ msg.sender ]
};

msg.method = "POST";
msg.url = "http://signal_cli:8080/v2/send";
return msg;

Import the flow

The complete flow - tab, both subflows (Query Regolo and Query Ollama with Tools), and the websocket-client config node - is available as a sanitised JSON file with all secrets replaced by placeholders (<bot-number>, <your-regolo-api-key>, <your-signal-uuid>, etc.):

Download signal-ai-assistant.json

In Node-RED: hamburger menu → Import → select the file. After importing, edit the websocket-client config node to point at your signal-cli-rest-api instance, fill in the Regolo API key in the Query Regolo subflow’s environment variables, and replace the UUID placeholders in the allow-list function.

At a glance
  • Bridge: signal-cli-rest-api (json-rpc mode)
  • Glue: Node-RED (Docker)
  • Cloud model: mistral-small-4-119b via Regolo
  • Local model: mistral-nemo:12b via Ollama
  • Context: 15-min TTL, 8 messages max
Want something similar?
I do contract sysadmin and automation work and can help wire up Signal bots, Node-RED flows, or local LLM infrastructure.
Contact me