If you landed here you probably searched for an OpenClaw Signal messenger integration setup guide. Good news: I just wired Signal into my own OpenClaw instance last week and wrote everything down while it was still fresh — every prerequisite, every cryptic error, and the places where Signal’s privacy model bites you compared to the laxer WhatsApp/Telegram flows.

Why use Signal with an AI agent at all?

Some teams live inside WhatsApp or Telegram and never look back. Signal users are different. They want end-to-end encryption that Facebook can’t peek into, don’t like phone-number scraping, and generally read every privacy policy they sign. If that describes your organization or your customers, integrating Signal into OpenClaw is the logical move.

The catch: Signal’s official API is basically non-existent for third-party bots. The workaround is signal-cli, an unofficial but battle-tested Java bridge that pretends to be a normal device linked to your real phone. That indirection preserves encryption but makes setup more involved.

Prerequisites: what you need before typing a single command

  • OpenClaw gateway v0.37.4+ and daemon running. (node -v should report 22.x)
  • A registered Signal phone number. It can be your real phone, a dedicated SIM, or a Twilio number capable of receiving SMS/voice.
  • Server or local machine with bash, curl, and at least Java 17 (signal-cli is Kotlin/Java).
  • ~500 MB free disk for the signal-cli JAR and its database.
  • Ports 5589/tcp outbound open. Signal uses this for WebSocket-style push.
  • Patience. First registration SMS can take a few minutes; linking QR expires in 60 s; you will redo it at least once.

Step 1 — Install signal-cli 0.11.4 (the version OpenClaw tested)

Signal-cli ships as a JAR plus a shell wrapper. Homebrew, AUR, and Scoop packages lag behind, so grab the GitHub release directly:

# Pick a directory OpenClaw can read mkdir -p /opt/signal && cd /opt/signal SIG_VER=0.11.4 curl -L -O https://github.com/AsamK/signal-cli/releases/download/v${SIG_VER}/signal-cli-${SIG_VER}.tar.gz tar xzf signal-cli-${SIG_VER}.tar.gz ln -s signal-cli-${SIG_VER} current sudo ln -s /opt/signal/current/bin/signal-cli /usr/local/bin/signal-cli signal-cli --version # should print 0.11.4

I hit an undocumented OpenSSL error on Ubuntu 22.04 due to Java’s outdated TLS cipher list. If that happens, update ca-certificates-java and retry.

Step 2 — Register or link a Signal number

Option A: fresh registration (SMS/voice)

PHONE="+15551234567" # Request a verification code via SMS signal-cli -u $PHONE register --sms # Wait for the 6-digit code on the device/voice line, then signal-cli -u $PHONE verify 000000

If you used a Twilio or virtual SIM, make sure you enabled SMS and voice. Signal sometimes rate-limits virtual carriers; if you never receive the code, fall back to voice: register --voice.

Option B: link to an existing phone (recommended)

Linking turns signal-cli into a secondary device, preserving chats and contacts.

signal-cli link -n "openclaw-agent" | qrencode -t ansiutf8

Scan the QR code in the Signal mobile app → Settings → Linked Devices → “Link new device”. You have ~1 minute before it expires. The CLI prints a long UUID; keep it, but OpenClaw stores it under ~/.local/share/signal-cli anyway.

Step 3 — Create a minimal Signal transport config for OpenClaw

OpenClaw discovers transports via JSON files in $HOME/.openclaw/transports. The Signal plugin is community-maintained (transport-signal) but works fine.

Install the plugin:

npm install -g @openclaw/transport-signal@0.5.2

Now drop a config file:

mkdir -p ~/.openclaw/transports cat > ~/.openclaw/transports/signal.json <<'EOF' { "type": "signal", "binary": "/usr/local/bin/signal-cli", "number": "+15551234567", "name": "signal-primary", "pollIntervalMs": 4000, "allowAttachments": true, "allowGroupMessages": false } EOF

Important knobs:

  • binary — Path to signal-cli. Don’t rely on $PATH; the daemon runs under nobody on ClawCloud.
  • pollIntervalMs — Signal doesn’t push on custom bridges; we poll. 4-5 s keeps latency acceptable without hammering the servers.
  • allowGroupMessages — Disabled by default because groups mix business and personal traffic. Enable if you’re sure.

Step 4 — Wire the transport into an agent

Fire up the gateway, create a new agent, and attach the transport:

# gateway port defaults to 5123 openclaw cli agent:create "SignalBot" --model gpt-4o openclaw cli transport:add signal-primary --agent "SignalBot"

Reload the agent in the web UI. You should see “Signal: +1 555-123-4567 (connected)”. Send “ping” from your Signal app. The OpenClaw log should show:

[signal] Rx msg 2024-07-15T12:34 from +15557654321: ping [agent:SignalBot] "pong" (16 tokens, 755 ms)

If nothing appears after 10 seconds, tail the daemon log. Most hiccups are one of:

  • Wrong Java version (Unsupported class file version 63)
  • Permissions: the nobody user can’t read ~/.config/signal-cli
  • Registration lost (Untrusted identity) — re-link.

Signal vs WhatsApp/Telegram: the trade-offs

All three give you end-users on a phone number. Only Signal encrypts metadata (who talks to whom) and message contents by default on every hop, including storage on their servers. That’s the upside.

The downsides I’ve seen running production agents for a month:

  • No official bot API. We rely on signal-cli reverse-engineering. It works today; it could break tomorrow.
  • High connect latency. Because we poll, first response adds 3-5 s if you set a conservative interval.
  • Phone linkage limits. Signal lets you link five secondary devices total. If you already use Desktop + iPad, you now have only three seats left for staging/production bridges.
  • No group web hooks. Even with allowGroupMessages:true, Signal throttles large groups (1000+). The CLI drops messages silently under load.
  • No read receipts. OpenClaw’s typing indicator works on WhatsApp/Telegram. Signal hides that from linked devices, so users get no “bot is typing…” feedback.

Whether these matter comes down to your SLA. For personal assistants or small internal teams, Signal is still my default choice.

Security model: what happens to encryption inside OpenClaw?

People ask if hooking an AI agent breaks end-to-end encryption. Short answer: not at the protocol level. Signal sees the OpenClaw box as another device with its own key pair. Long answer:

  1. Messages remain encrypted in transit between Signal’s servers and each device, including the agent.
  2. Once signal-cli decrypts a message locally, it writes cleartext to stdout. OpenClaw ingests that plaintext just like it would from any other transport.
  3. If you route the text to OpenAI or another LLM API, it leaves your infrastructure unencrypted unless you use TLS and data-protection features on the model vendor’s side.
  4. Attachments are stored under ~/.local/share/signal-cli/attachments as clear files. Run a nightly cron to shred old blobs if compliance matters.

TL;DR: Signal’s crypto does its job until your agent touches the data. After that the responsibility is yours. Encrypt disks, restrict logs, and avoid echoing full messages in verbose mode.

Troubleshooting playbook

“Failed to connect to websocket”

Your server can’t reach textsecure-service.whispersystems.org:443. Check firewalls, corporate proxies, or inspect with curl -v https://textsecure-service.whispersystems.org.

“Untrusted identity key” spam

Someone reinstalled Signal and changed device keys. Clear the cache:

signal-cli -u +15551234567 trust --verify all

Then restart the transport.

Agent replies twice

You accidentally ran two OpenClaw daemons pointing at the same signal.json. Systemd will happily spawn duplicates if you used --user units. Use systemctl --global status openclaw-daemon to verify one instance.

“account locked” after too many polls

Signal’s anti-spam kicks in if you hammer the API. Lower pollIntervalMs or implement the long-polling beta (issue #1464).

Automating the setup on ClawCloud

ClawCloud containers start as root but drop to nobody. You can bake everything into the startup.sh hook:

#!/bin/bash set -euo pipefail cd /opt/signal || exit 1 if [[ ! -f current/bin/signal-cli ] ]; export function toISO(dateStr: string, zone: string) { return DateTime.fromISO(dateStr, { zone }).toUTC().toISO(); } export function nice(dt: DateTime) { return dt.setLocale("en").toFormat("ccc, dd LLL yyyy HH:mm ZZZZ"); }

When we read a participant’s calendar events, we’ll store them in UTC. At display time we convert back to the participant’s zone.

Collecting availability from participants

Your first temptation might be brute-force 24×7 polling. That triggers API quotas quickly. Instead we:

  1. Detect each participant’s calendar working hours.
  2. Map them to UTC for a shared availability matrix.
  3. Intersect the slots.

Agent code (scheduler.ts) below. Truncated for space but these ~40 lines do the core job.

// scheduler.ts import { Agent } from "openclaw"; import { googleCalendar } from "composio-sdk"; import { DateTime, Interval } from "luxon"; import { toISO } from "./tz-util"; export const scheduler = new Agent("scheduler", async (ctx) => { const { members, durationMin, daysAhead } = ctx.input; // 1. Fetch events in parallel const calendars = await Promise.all( members.map((m: any) => googleCalendar.events.list({ calendarId: "primary", timeMin: new Date().toISOString(), timeMax: DateTime.utc().plus({ days: daysAhead }).toISO(), singleEvents: true, orderBy: "startTime", auth: m.token, }) ) ); // 2. Build busy intervals per user const busy: Interval[][] = calendars.map((res) => res.items.map((e: any) => Interval.fromISO(`${e.start.dateTime}/${e.end.dateTime}`) ) ); // 3. Scan day chunks and pick a slot everyone is free const slot = findSlot(busy, durationMin); return { slot }; });

Gotcha: Google Calendar returns events in the event owner’s zone, not yours. Normalize immediately with toISO.

findSlot helper

// find-slot.ts export function findSlot(busy: Interval[][], duration: number) { const start = DateTime.utc().plus({ hours: 2 }); // give folks lead time const end = start.plus({ days: 14 }); let cursor = start; const step = { minutes: 15 }; while (cursor < end) { const candidate = Interval.fromDateTimes(cursor, cursor.plus({ minutes: duration })); const clash = busy.some((b) => b.some((i) => i.overlaps(candidate))); if (!clash) return candidate; cursor = cursor.plus(step); } throw new Error("No common slot found in next 14 days"); }

We now have a UTC start/end pair everyone can attend. Time to notify people.

Polling users via Slack and email

OpenClaw’s gateway already knows each user’s Slack ID (if you linked Slack). We send interactive messages with three buttons: 👍 works, 🤔 maybe, 👎 can’t.

// notify.ts import { slack } from "composio-sdk"; import { nice } from "./tz-util"; export async function askApproval(members, slot) { const dt = nice(slot.start); for await (const m of members) { await slack.chat.postMessage({ channel: m.slackId, text: `Proposed meeting: ${dt} (${m.tz}). Respond with /accept, /tentative, /decline`, auth: m.token, }); } }

Yes, polling via interactive Slack buttons would be cleaner, but custom slash commands avoid the “App Home” install step. Trade-offs.

Users on email-only get a standard RFC 5545 iCalendar invite with PARTSTAT=NEEDS-ACTION. They click accept and Google sends a webhook back (Composio does the plumbing).

Once we have unanimous 👍 or a quorum you define, we book the slot.

// book.ts import { googleCalendar, zoom } from "composio-sdk"; export async function book(slot, members) { // 1. Create Zoom meeting const zoomRes = await zoom.meetings.create({ topic: "Team Sync", start_time: slot.start.toISO(), duration: slot.length("minutes"), timezone: "UTC", }); const meetingUrl = zoomRes.join_url; // 2. Create calendar event for each participant for await (const m of members) { await googleCalendar.events.insert({ calendarId: "primary", sendUpdates: "all", requestBody: { summary: "Team Sync", description: `Zoom: ${meetingUrl}`, start: { dateTime: slot.start.toISO() }, end: { dateTime: slot.end.toISO() }, attendees: members.map((x) => ({ email: x.email })), conferenceData: { createRequest: { requestId: `openclaw-${Date.now()}` }, }, }, auth: m.token, }); } }

Heads-up: If you’re on a free Zoom plan, API-created meetings have a 40-minute cap. Swap with Google Meet by replacing the Zoom step with conferenceData request only.

You can expose the agent via the gateway’s HTTP API. Let’s mount /book and accept query params ?members=alice,bob&duration=30.

// api.ts import express from "express"; import { scheduler } from "./scheduler"; import { askApproval } from "./notify"; import { book } from "./book"; const app = express(); app.use(express.json()); app.post("/book", async (req, res) => { try { const { members, duration } = req.body; const { slot } = await scheduler.run({ members, durationMin: duration, daysAhead: 14 }); await askApproval(members, slot); return res.json({ slot }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.listen(3030, () => console.log("Scheduler API on :3030"));

Behind Nginx you can assign https://meet.your-org.dev/. People hit it, pick duration, paste participant emails, done. That URL is your Calendly.

Putting the whole thing on autopilot

Human nature: someone will forget to click 👍 in Slack. I added a reminder job:

// remind.ts import { createCron } from "openclaw"; import { slack } from "composio-sdk"; createCron("0 * * * *", async () => { // every hour const pending = await db.pendingApprovals.findMany(); for (const p of pending) { await slack.chat.postMessage({ channel: p.slackId, text: `Reminder: please confirm the ${p.meeting} invite`, }); } });

OpenClaw’s daemon keeps cron tasks alive even if the gateway restarts (openclaw daemon start).

Security considerations

  • Least-privilege OAuth scopes: Composio lets you pick. You only need calendar.events.readonly + calendar.events.write. Don’t grant Gmail if you don’t use it.
  • Token storage: The default sqlite store is AES-256 encrypted. For enterprise, back it with HashiCorp Vault via OpenClaw’s vault: driver.
  • Audit logs: Enable LOG_LEVEL=debug when reproducing bugs, but turn it off in prod—calendar events may contain PII.
  • Rate limits: Google Calendar hard-caps at 10 requests/s per user. Batch calls or spread them with p-limit.

Testing end-to-end

Integration tests catch time zone edge cases early. I use Vitest with a fixed fake clock.

// scheduler.test.ts import { describe, it, expect, vi } from "vitest"; import { scheduler } from "./scheduler"; describe("finds slot across DST", () => { it("handles Europe/Berlin to America/New_York", async () => { vi.setSystemTime(new Date("2024-10-27T00:00:00Z")); // day Europe DST ends const slot = await scheduler.run({ members: demoMembers, durationMin: 30, daysAhead: 3, }); expect(slot).toBeDefined(); }); });

Set the fake clock to DST transition days. You don’t want a 9 AM meeting turning into 8 AM for half the team.

Cost breakdown

  • OpenClaw OSS: free (MIT)
  • ClawCloud hosting (optional): $29/mo for 3 agents, includes 1 M tool invocations
  • Composio free tier: 2 K actions/mo
  • Zoom API: included in paid Zoom account, else 40-minute limit

Total for a 10-person team: roughly $0 if self-hosted, $29/mo if you want ClawCloud’s managed Redis + HTTPS certs.

What we learned

OpenClaw lets you outgrow point-solution schedulers without building a whole microservice: read calendars, crunch time zones with Luxon, push Slack prompts, and finally create the event. The agent above took a weekend to write; most of the hours went into OAuth fiddling, not scheduling logic. If you’re drowning in "does this time work?" threads, put this repo behind a URL and move on to work that matters.

Next step: fork the sample code (link below), wire your OAuth creds, and ship your own /book endpoint. Questions or patches—open an issue on GitHub, I watch that repo daily.