Channels
marsClaw ships three messaging channels: Telegram, Slack, and WhatsApp. Each is optional; enable any combination. Adapters live in src/channels/ and implement the Channel interface in src/channels/types.ts.
interface Channel {
send(threadId: string, text: string, opts?: SendOpts): Promise<void>;
setTyping?(threadId: string): Promise<void>;
}
Thread IDs are channel-prefixed (telegram:, slack:, whatsapp:); the ChannelRouter uses the prefix to dispatch outbound sends back to the right adapter.
Telegram
The simplest channel to set up.
First-time setup
- Talk to @BotFather,
/newbot, follow the prompts. You'll get a token like123456:ABC-DEF…. - Run
bun run setupand answeryto "Enable Telegram?". Paste the token when prompted. Setup writes it to.envasTELEGRAM_BOT_TOKEN=…and offers to capture an optional chat-id allow-list (see below). - Restart the bot. Message your bot from Telegram.
Manual route, if you'd rather skip the wizard:
TELEGRAM_BOT_TOKEN=123456:ABC-DEF…
Telegram supports text in/out and the typing indicator. No images, no voice notes on this adapter — Telegram is the boring-and-reliable channel. Adapter: src/channels/telegram.ts.
Setup is idempotent
Re-running bun run setup picks up the existing TELEGRAM_BOT_TOKEN and offers enter to keep current when prompting for the token, so you only paste it once. The prompt also validates the shape (<digits>:<base64-ish>) and asks for confirmation if it looks wrong.
Disabling without losing the token
Answer n to "Enable Telegram?" and setup comments out the token rather than deleting it:
# TELEGRAM_BOT_TOKEN=123456:ABC-DEF…
The runtime keys off the presence of an uncommented token, so the channel goes dark — but you can re-enable later by uncommenting that line (or by re-running setup) without bothering @BotFather again.
Allow-list
Telegram has no phone-based identity, so the only inbound gate is a chat-id allow-list. Setup will ask:
Allowed chat ids (comma-separated): 123456789,987654321
Empty means accept any sender. Most owners don't know their chat id yet — the bot logs each new sender at info level on first message, so the usual flow is: leave it empty initially, send a message to the bot from your phone, copy the chat id from the logs into data/config.json:
{
"allowed_telegram_chats": ["123456789"]
}
or shadow it via MARSCLAW_TELEGRAM_ALLOWED_CHATS=123456789 in .env. On boot the log echoes which mode you're in.
Slack
Uses Socket Mode — no public webhook needed.
- Create a Slack app at api.slack.com/apps. Pick "From scratch".
- OAuth & Permissions → bot token scopes:
chat:write,im:history,im:read,im:write,app_mentions:read,users:read. Install to your workspace. Copyxoxb-…→SLACK_BOT_TOKEN. - Basic Information → App-Level Tokens → "Generate Token and Scopes" with scope
connections:write. Copyxapp-…→SLACK_APP_TOKEN. - Socket Mode → enable.
- Event Subscriptions → enable, subscribe to bot events:
message.im,app_mention. - Restart the bot. DM the app from Slack.
Adapter: src/channels/slack.ts. Lazy-loaded — non-Slack users don't pay the @slack/bolt import cost.
WhatsApp
The richest channel: text, images (vision), voice notes in and out, document attachments. Uses Baileys — WhatsApp's web-multi-device protocol — so no Business API account needed.
First-time link
Setup will walk you through this; the bot can be started inside the setup flow so the QR appears live. Manual route:
MARSCLAW_WHATSAPP=1
Then bun run start and scan the QR with your phone:
WhatsApp → Settings → Linked devices → Link a device
Auth is saved to data/whatsapp-auth/ (gitignored). Subsequent boots reconnect silently.
Owner pairing
WhatsApp's @lid identifiers can differ from the phone-derived @s.whatsapp.net JID, so on a fresh link the bot can't tell which incoming JID is "you". Setup writes a one-shot pairing code into data/config.json: send the printed code from your phone, the bot captures the real JID into allowed_jids, and the code is cleared.
Allow-list
Set allowed_jids in data/config.json (or MARSCLAW_WHATSAPP_ALLOWED_JIDS=… in env) to restrict who can talk to the bot. Empty list = allow anyone. On boot the log will say which mode you're in:
[whatsapp] allow-list active count=2
[whatsapp] allow-list disabled — accepting from any sender
Useful commands
bun run whatsapp status # link state + cached media count
bun run whatsapp reset # wipe auth → forces a new QR
bun run whatsapp clear-media # purge data/whatsapp-media/
Images
Inbound images are downloaded to data/whatsapp-media/ and passed to the agent as @<path> so Claude/Gemini vision can read them. Caption text (if any) accompanies the image.
Voice
If MARSCLAW_VOICE=1 and the Whisper sidecar is running, inbound voice notes are transcribed and prepended with [Voice]: so the agent knows to reply via the speak MCP tool. See voice.md.
Verbose protocol logs
MARSCLAW_WHATSAPP_VERBOSE=1 bun run start
Adapter: src/channels/whatsapp.ts, pairing: src/channels/whatsapp-link.ts.
Adding a new channel
Roughly ~60 lines. The skeleton:
// src/channels/mychannel.ts
import type { Channel, ChannelInit } from './types.ts';
export function createMyChannel(opts: { token: string } & ChannelInit): Channel {
// 1. connect to the upstream SDK
// 2. on inbound message → opts.onMessage(`mychannel:${threadId}`, text)
// 3. expose send(threadId, text, opts) — strip the `mychannel:` prefix
return {
async send(threadId, text, sendOpts) { /* ... */ },
async setTyping(threadId) { /* optional */ },
};
}
Then wire it up in src/index.ts behind a feature flag and the prefix in src/channels/router.ts is automatic — the router routes by the prefix you used.
Failure modes
| Symptom | Channel | Fix |
|---|---|---|
Connected but no replies; [whatsapp] in append | Replaying history (type: append); only notify is processed. Send a fresh message. | |
code=405/428 cycling | Outdated Baileys protocol. bun update baileys | |
[whatsapp] giving up after 5 failed connection attempts | Too many linked devices, or geo block. Unlink from phone, or try another network. | |
Same reply 2-3 times | any | Was a drain-race bug. Pull latest; restart. |
[slack] missing scope | Slack | Add the listed scope, reinstall app. |
| Telegram bot doesn't see DMs | Telegram | /setprivacy in BotFather to disable group-only privacy (irrelevant for DMs but a common red herring). |