Skip to main content

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

  1. Talk to @BotFather, /newbot, follow the prompts. You'll get a token like 123456:ABC-DEF….
  2. Run bun run setup and answer y to "Enable Telegram?". Paste the token when prompted. Setup writes it to .env as TELEGRAM_BOT_TOKEN=… and offers to capture an optional chat-id allow-list (see below).
  3. 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.

  1. Create a Slack app at api.slack.com/apps. Pick "From scratch".
  2. OAuth & Permissions → bot token scopes: chat:write, im:history, im:read, im:write, app_mentions:read, users:read. Install to your workspace. Copy xoxb-…SLACK_BOT_TOKEN.
  3. Basic Information → App-Level Tokens → "Generate Token and Scopes" with scope connections:write. Copy xapp-…SLACK_APP_TOKEN.
  4. Socket Mode → enable.
  5. Event Subscriptions → enable, subscribe to bot events: message.im, app_mention.
  6. 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.

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

SymptomChannelFix
Connected but no replies; [whatsapp] in appendWhatsAppReplaying history (type: append); only notify is processed. Send a fresh message.
code=405/428 cyclingWhatsAppOutdated Baileys protocol. bun update baileys
[whatsapp] giving up after 5 failed connection attemptsWhatsAppToo many linked devices, or geo block. Unlink from phone, or try another network.
Same reply 2-3 timesanyWas a drain-race bug. Pull latest; restart.
[slack] missing scopeSlackAdd the listed scope, reinstall app.
Telegram bot doesn't see DMsTelegram/setprivacy in BotFather to disable group-only privacy (irrelevant for DMs but a common red herring).