Send, receive, auto-reply, and inspect WhatsApp messages over Twilio or your personal WhatsApp Web session. Ships with a one-command webhook setup (Tailscale Funnel + Twilio callback) and a configurable auto-reply engine (plain text or command/Claude driven).
I'm using warelay to run my personal, pro-active assistant, Clawd. Follow me on Twitter: @steipete. This project is brand-new and there's a lot to discover. See the exact Claude setup in docs/clawd.md.
I'm using warelay to run my personal, pro-active assistant, Clawd. Follow me on Twitter - @steipete, this project is brand-new and there's a lot to discover.
Install from npm (global): npm install -g warelay (Node 22+). Then choose one path:
A) Personal WhatsApp Web (preferred: no Twilio creds, fastest setup)
- Link your account:
warelay login(scan the QR). - Send a message:
warelay send --to +12345550000 --message "Hi from warelay"(add--provider webif you want to force the web session). - Stay online & auto-reply:
warelay relay --verbose(uses Web when you're logged in; if you're not linked, start it with--provider twilio). When a Web session drops, the relay exits instead of silently falling back so you notice and re-login.
B) Twilio WhatsApp number (for delivery status + webhooks)
- Copy
.env.example→.env; setTWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKENorTWILIO_API_KEY/TWILIO_API_SECRET, andTWILIO_WHATSAPP_FROM=whatsapp:+19995550123(optionalTWILIO_SENDER_SID). - Send a message:
warelay send --to +12345550000 --message "Hi from warelay". - Receive replies:
- Polling (no ingress):
warelay relay --provider twilio --interval 5 --lookback 10 - Webhook + public URL via Tailscale Funnel:
warelay webhook --ingress tailscale --port 42873 --path /webhook/whatsapp --verbose
- Polling (no ingress):
Already developing locally? You can still run
pnpm installandpnpm warelay ...from the repo, but end users only need the npm package.
- Two providers: Twilio (default) for reliable delivery + status; Web provider for quick personal sends/receives via QR login.
- Auto-replies: Static templates or external commands (Claude-aware), with per-sender or global sessions and
/newresets. - Claude setup guide: see
docs/claude-config.mdfor the exact Claude CLI configuration we support. - Webhook in one go:
warelay webhook --ingress tailscaleenables Tailscale Funnel, runs the webhook server, and updates the Twilio sender callback URL. - Polling fallback:
relaypolls Twilio when webhooks aren’t available; works headless. - Status + delivery tracking:
statusshows recent inbound/outbound;sendcan wait for final Twilio status.
| Command | What it does | Core flags |
|---|---|---|
warelay send |
Send a WhatsApp message (Twilio or Web) | --to <e164> --message <text> --wait <sec> --poll <sec> --provider twilio|web --json --dry-run --verbose |
warelay relay |
Auto-reply loop (poll Twilio or listen on Web) | --provider <auto|twilio|web> --interval <sec> --lookback <min> --verbose |
warelay status |
Show recent sent/received messages | --limit <n> --lookback <min> --json --verbose |
warelay heartbeat |
Trigger one heartbeat poll (web) | --provider <auto|web> --to <e164?> --session-id <uuid?> --all --verbose |
warelay relay:heartbeat |
Run relay with an immediate heartbeat (no tmux) | --provider <auto|web> --verbose |
warelay relay:heartbeat:tmux |
Start relay in tmux and fire a heartbeat on start (web) | no flags |
warelay webhook |
Run inbound webhook (ingress=tailscale updates Twilio; none is local-only) |
--ingress tailscale|none --port <port> --path <path> --reply <text> --verbose --yes --dry-run |
warelay login |
Link personal WhatsApp Web via QR | --verbose |
- Twilio:
warelay send --to +1... --message "Hi" --media ./pic.jpg --serve-media(needswarelay webhook --ingress tailscaleor--serve-mediato auto-host via Funnel; max 5 MB per file because of the built-in host). - Web:
warelay send --provider web --media ./pic.jpg --message "Hi"(local path or URL; no hosting needed). Web auto-detects media kind: images (≤6 MB), audio/voice or video (≤16 MB), other docs (≤100 MB). Images are resized to max 2048px and JPEG recompressed when the cap would be exceeded. - Auto-replies can attach
mediaUrlin~/.warelay/warelay.json(used alongsidetextwhen present). Web auto-replies honorinbound.reply.mediaMaxMb(default 5 MB) as a post-compression target but will never exceed the provider hard limits above.
- If you set
inbound.transcribeAudio.command, warelay will run that CLI when inbound audio arrives (e.g., WhatsApp voice notes) and replace the Body with the transcript before templating/Claude. - Example using OpenAI Whisper CLI (requires
OPENAI_API_KEY):{ inbound: { transcribeAudio: { command: [ "openai", "api", "audio.transcriptions.create", "-m", "whisper-1", "-f", "{{MediaPath}}", "--response-format", "text" ], timeoutSeconds: 45 }, reply: { mode: "command", command: ["claude", "{{Body}}"] } } }
- Works for Web and Twilio providers; verbose mode logs when transcription runs. The command prompt includes the original media path plus a
Transcript:block so models see both. If transcription fails, the original Body is used.
- Twilio (default): needs
.envcreds + WhatsApp-enabled number; supports delivery tracking, polling, webhooks, and auto-reply typing indicators. - Web (
--provider web): uses your personal WhatsApp via Baileys; supports send/receive + auto-reply, but no delivery-status wait; cache lives in~/.warelay/credentials/(rerunloginif logged out). If the Web socket closes, the relay exits instead of pivoting to Twilio. - Auto-select (
relayonly):--provider autopicks Web when a cache exists at start, otherwise Twilio polling. It will not swap from Web to Twilio mid-run if the Web session drops.
Best practice: use a dedicated WhatsApp account (separate SIM/eSIM or business account) for automation instead of your primary personal account to avoid unexpected logouts or rate limits.
warelay supports running on the same phone number you message from—you chat with yourself and an AI assistant replies in the same bubble. This requires:
- Adding your own number to
allowFrominwarelay.json - The
fromMefilter is disabled; echo detection inauto-reply.tsprevents loops
Gotchas:
- Messages appear in the same chat bubble (WhatsApp "Note to self")
- Echo detection relies on exact text matching; if the reply is identical to your input, it may be skipped
- Works best with a dedicated WhatsApp account
| Variable | Required | Description |
|---|---|---|
TWILIO_ACCOUNT_SID |
Yes (Twilio provider) | Twilio Account SID |
TWILIO_AUTH_TOKEN |
Yes* | Auth token (or use API key/secret) |
TWILIO_API_KEY |
Yes* | API key if not using auth token |
TWILIO_API_SECRET |
Yes* | API secret paired with TWILIO_API_KEY |
TWILIO_WHATSAPP_FROM |
Yes (Twilio provider) | WhatsApp-enabled sender, e.g. whatsapp:+19995550123 |
TWILIO_SENDER_SID |
Optional | Overrides auto-discovery of the sender SID |
(*Provide either auth token OR api key/secret.)
- Controls who is allowed to trigger replies (
allowFrom), reply mode (textorcommand), templates, and session behavior. - Example (Claude command):
{
inbound: {
allowFrom: ["+12345550000"],
reply: {
mode: "command",
bodyPrefix: "You are a concise WhatsApp assistant.\n\n",
command: ["claude", "--dangerously-skip-permissions", "{{BodyStripped}}"],
claudeOutputFormat: "text",
session: { scope: "per-sender", resetTriggers: ["/new"], idleMinutes: 60 },
heartbeatMinutes: 10 // optional; pings Claude every 10m with "HEARTBEAT ultrathink" and only sends if it omits HEARTBEAT_OK
}
}
}- When
heartbeatMinutesis set (default 10 formode: "command"), the relay periodically runs your command/Claude session with a heartbeat prompt. - Heartbeat body is
HEARTBEAT ultrathink(so the model can recognize the probe); if Claude replies exactlyHEARTBEAT_OK, the message is suppressed; otherwise the reply (or media) is forwarded. Suppressions are still logged so you know the heartbeat ran. - Override session freshness for heartbeats with
session.heartbeatIdleMinutes(defaults tosession.idleMinutes). Heartbeat skips do not bumpupdatedAt, so sessions still expire normally. - Trigger one manually with
warelay heartbeat(web provider only,--verboseprints session info). Use--session-id <uuid>to force resuming a specific Claude session,--allto ping every active session,warelay relay:heartbeatfor a full relay run with an immediate heartbeat, or--heartbeat-nowonrelay/relay:heartbeat:tmux. - When multiple active sessions exist,
warelay heartbeatrequires--to <E.164>or--all; ifallowFromis just"*", you must choose a target with one of those flags.
- File logs are written to
/tmp/warelay/warelay.logby default. Levels:silent | fatal | error | warn | info | debug | trace(CLI--verboseforcesdebug). Web-provider inbound/outbound entries include message bodies and auto-reply text for easier auditing. - Override in
~/.warelay/warelay.json:
{
logging: {
level: "warn",
file: "/tmp/warelay/custom.log"
}
}- Install the official Claude CLI (e.g.,
brew install anthropic-ai/cli/claudeor follow the Anthropic docs) and runclaude loginso it can read your API key. - In
warelay.json, setreply.modeto"command"and pointcommand[0]to"claude"; setclaudeOutputFormatto"text"(or"json"/"stream-json"if you want warelay to parse and trim the JSON output). - (Optional) Add
bodyPrefixto inject a system prompt andsessionsettings to keep multi-turn context (/newresets by default). SetsendSystemOnce: true(plus an optionalsessionIntro) to only send that prompt on the first turn of each session. - Run
pnpm warelay relay --provider auto(or--provider web|twilio) and send a WhatsApp message; warelay will queue the Claude call, stream typing indicators (Twilio provider), parse the result, and send back the text.
| Key | Type & default | Notes |
|---|---|---|
inbound.allowFrom |
string[] (default: empty) |
E.164 numbers allowed to trigger auto-reply (no whatsapp:); "*" allows any sender. |
inbound.messagePrefix |
string (default: "[warelay]" if no allowFrom, else "") |
Prefix added to all inbound messages before passing to command. |
inbound.responsePrefix |
string (default: —) |
Prefix auto-added to all outbound replies (e.g., "🦞"). |
inbound.timestampPrefix |
boolean | string (default: true) |
Timestamp prefix: true (UTC), false (disabled), or IANA timezone like "Europe/Vienna". |
inbound.reply.mode |
"text" | "command" (default: —) |
Reply style. |
inbound.reply.text |
string (default: —) |
Used when mode=text; templating supported. |
inbound.reply.command |
string[] (default: —) |
Argv for mode=command; each element templated. Stdout (trimmed) is sent. |
inbound.reply.template |
string (default: —) |
Injected as argv[1] (prompt prefix) before the body. |
inbound.reply.bodyPrefix |
string (default: —) |
Prepended to Body before templating (great for system prompts). |
inbound.reply.timeoutSeconds |
number (default: 600) |
Command timeout. |
inbound.reply.claudeOutputFormat |
"text"|"json"|"stream-json" (default: —) |
When command starts with claude, auto-adds --output-format + -p/--print and trims reply text. |
inbound.reply.session.scope |
"per-sender"|"global" (default: per-sender) |
Session bucket for conversation memory. |
inbound.reply.session.resetTriggers |
string[] (default: ["/new"]) |
Exact match or prefix (/new hi) resets session. |
inbound.reply.session.idleMinutes |
number (default: 60) |
Session expires after idle period. |
inbound.reply.session.store |
string (default: ~/.warelay/sessions.json) |
Custom session store path. |
inbound.reply.session.sendSystemOnce |
boolean (default: false) |
If true, only include the system prompt/template on the first turn of a session. |
inbound.reply.session.sessionIntro |
string |
Optional intro text sent once per new session (prepended before the body when sendSystemOnce is used). |
inbound.reply.typingIntervalSeconds |
number (default: 8 for command replies) |
How often to refresh typing indicators while the command/Claude run is in flight. |
inbound.reply.session.sessionArgNew |
string[] (default: ["--session-id","{{SessionId}}"]) |
Args injected for a new session run. |
inbound.reply.session.sessionArgResume |
string[] (default: ["--resume","{{SessionId}}"]) |
Args for resumed sessions. |
inbound.reply.session.sessionArgBeforeBody |
boolean (default: true) |
Place session args before final body arg. |
Templating tokens: {{Body}}, {{BodyStripped}}, {{From}}, {{To}}, {{MessageSid}}, plus {{SessionId}} and {{IsNewSession}} when sessions are enabled.
warelay webhook --ingress nonestarts the local Express server on your chosen port/path; add--reply "Got it"for a static reply when no config file is present.warelay webhook --ingress tailscaleenables Tailscale Funnel, prints the public URL (https://<tailnet-host><path>), starts the webhook, discovers the WhatsApp sender SID, and updates Twilio callbacks to the Funnel URL.- If Funnel is not allowed on your tailnet, the CLI exits with guidance; you can still use
relay --provider twilioto poll without webhooks.
- Send/receive issues: run
pnpm warelay status --limit 20 --lookback 240 --jsonto inspect recent traffic. - Auto-reply not firing: ensure sender is in
allowFrom(or unset), and confirm.env+warelay.jsonare loaded (reload shell after edits). - Web provider dropped: rerun
pnpm warelay login; credentials live in~/.warelay/credentials/. - Tailscale Funnel errors: update tailscale/tailscaled; check admin console that Funnel is enabled for this device.
- Web logic lives under
src/web/:session.ts(auth/cache + provider pick),login.ts(QR login/logout),outbound.ts/inbound.ts(send/receive plumbing),auto-reply.ts(relay loop + reconnect/backoff),media.ts(download/resize helpers), andreconnect.ts(shared retry math).test-helpers.tsprovides fixtures. - The public surface remains the
src/provider-web.tsbarrel so existing imports keep working. - Reconnects are capped and logged; no Twilio fallback occurs after a Web disconnect—restart the relay after re-linking.
- Twilio errors: 63016 “permission to send an SMS has not been enabled” → ensure your number is WhatsApp-enabled; 63007 template not approved → send a free-form session message within 24h or use an approved template; 63112 policy violation → adjust content, shorten to <1600 chars, avoid links that trigger spam filters. Re-run
pnpm warelay statusto see the exact Twilio response body. - Does this store my messages? warelay only writes
~/.warelay/warelay.json(config),~/.warelay/credentials/(WhatsApp Web auth), and~/.warelay/sessions.json(session IDs + timestamps). It does not persist message bodies beyond the session store. Logs stream to stdout/stderr and also/tmp/warelay/warelay.log(configurable vialogging.file). - Personal WhatsApp safety: Automation on personal accounts can be rate-limited or logged out by WhatsApp. Use
--provider websparingly, keep messages human-like, and re-runloginif the session is dropped. - Limits to remember: WhatsApp text limit ~1600 chars; avoid rapid bursts—space sends by a few seconds; keep webhook replies under a couple seconds for good UX; command auto-replies time out after 600s by default.
- Deploy / keep running: Use
tmuxorscreenfor ad-hoc (tmux new -s warelay -- pnpm warelay relay --provider twilio). For long-running hosts, wrappnpm warelay relay ...orpnpm warelay webhook --ingress tailscale ...in a systemd service or macOS LaunchAgent; ensure environment variables are loaded in that context. - Rotating credentials: Update
.env(Twilio keys), rerun your process; for Web provider, delete~/.warelay/credentials/and rerunpnpm warelay loginto relink.
