MINARA

Messaging & Notifications

Overview of the 18 messaging providers, how to pick one, set it up, and use it from the agent.

Minara can push messages out of the agent loop to external platforms. This is how autopilot notifies you of trades, how scheduled workflows alert you on watchlist moves, and how the agent reaches you when you're away from the REPL.

Eighteen platforms ship out of the box, grouped by category. Each platform has its own setup page with credential steps, inbound webhook shape, signing recipe, and platform-specific limits.

Most operators start here:

  • Telegram, recommended starting point; streaming edits
  • Discord, bot + channel; streaming edits (throttled 1 s)
  • Slack, webhook or bot token (streaming only via bot)

Enterprise IM:

Consumer / social:

  • WeChat OA (公众号), customer-service messaging within the 48 h window
  • QQ Bot, Ed25519 webhook; passive replies (active push is 4/month)
  • LINE, Messaging API push + signed webhook

Federated / niche:

Notifications & non-chat:

  • Email, SMTP; send-only, subject auto-split
  • Signal, via local signal-cli subprocess; send-only
  • Home Assistant, any notify.* service; send-only

The easy way: just ask Minara

The fastest way to set up a messaging channel is to tell Minara in chat. The agent walks you through the credential steps, runs a test message, and saves the config to ~/.minara/env for you.

set up Telegram notifications — I want trade alerts

connect Slack to channel #trades using my bot token

configure email alerts — I'll give you the SMTP settings

send me a test message on Telegram to make sure it works

what notification channels are configured right now?

turn off the discord gateway, I'm not using it anymore

when ETH breaks $4000, alert me on Telegram

For the last prompt, Minara sets up a background workflow that uses the messaging channel you configured — the agent wires the alert condition, the channel, and the tool-set allowlist in one step.

Minara confirms credential writes before saving (touching ~/.minara/env is a tier-3 action), masks secrets when echoing them back, and runs a test message automatically once the channel is configured.

The manual way

Three surfaces share the same store and the same hot-reload semantics — every change reaches the running agent without a restart.

From the shell (minara gateway)

minara gateway list                     # show configured providers
minara gateway add                      # interactive picker, all 18 platforms
minara gateway add <provider>           # interactive wizard, masks tokens
minara gateway test <provider>          # send a test message
minara gateway remove <provider>        # strip credentials

Running minara gateway add without a provider id drops into an interactive menu that lists every supported platform, shows which are already configured, and walks you through credentials for the one you pick. After saving you can ping-test, configure another, or quit. See CLI subcommands for the full flow.

From inside the REPL (/connect)

/connect                       # numbered chooser
/connect telegram              # interactive credential entry
/connect slack --test          # test ping
/connect telegram --remove     # wipe credentials

The slash command is the right surface when you're mid-conversation and just realized you need a platform connected. Optional fields auto-skip on blank; re-config shows (currently set, blank to keep) on each existing field. Token entry is currently visible on screen in the slash command (the CLI subcommand masks it) — for sensitive credentials prefer the CLI or set the env vars in your shell rc.

From the web UI (Settings → Messaging)

The web UI surfaces every provider in a single panel:

  • Status badge per provider (runtime ready / configured / not connected).
  • Per-row Save / Test / Disconnect buttons.
  • The Test button sends an actual ping — you see the message in the IM/email client and the row tags itself last test ✓.
  • Empty input + Save clears that field on disk (same Save = clear semantics as a typical settings form). Use this to switch Slack modes from bot-token to webhook-only without running --remove.

Credentials are stored in ~/.minara/env (mode 0600, git-ignored), so they survive reinstalls and never leak into the repo. All three surfaces hot-reload the running agent's gateway map after a write — no process restart needed.

Workflows: the send_message step kind

Workflows can send messages as a first-class step kind, not just through the tool_call: send_message form:

{
  "name": "notify_team",
  "kind": "send_message",
  "provider": "slack",
  "channel": "#alerts",
  "text": "BTC crossed ${trigger.threshold}"
}

Provider resolution at dispatch:

  1. step.provider (explicit per-step).
  2. definition.delivery.provider (workflow-level default).
  3. The single connected provider, when exactly one is connected.
  4. Otherwise → activation refused with a structured error pointing you at /connect <platform> (REPL) or Settings → Messaging (web UI).

The activation gate is independent of the autopilot switch — sending a notification doesn't move funds, so the workflow can be activated and run with autopilotEnabled=false. The same independence applies to cron-triggered messaging and autopilot's own success notifications.

When activation fails because a target platform isn't connected, the web UI surfaces a modal with a one-click Connect <provider> button that takes you to Settings → Messaging with the right row pre-expanded; on Save the page returns you to the workflow and auto-retries the activation.

Verifying a messaging workflow with workflow_test

workflow_test is the recommended way to confirm a new platform setup end-to-end. It runs the DAG with a sample trigger and fires a real [TEST] -prefixed message through the same channel production would use, so you see the alert hit your phone before turning the workflow on. If the target provider isn't connected, the test refuses with a structured messaging_not_configured error pointing you at /connect <provider> (REPL) or Settings → Messaging (web UI). Fund- moving and other destructive tools in the same workflow remain simulated — only messaging steps live-fire. See Testing a local workflow before deployment for the full flow.

Fire-once alerts

For "ping me when X happens, then stop" workflows, append a deactivate step after send_message. The workflow flips its own active = false after delivery so the trigger never re-fires. See the fire-once alerts recipe for the full JSON template.

Capability matrix

All providers support plain text send — that's the baseline and the matrix doesn't track it. The columns below describe advanced capabilities layered on top.

ProviderStreamImagesFilesVoiceThreadsTypingReactionsRichInboundChar limit
telegram✅ (OGG)4 096
discord2 000
slack40 000
email1 000 000
whatsapp4 096
signal4 096
home_assistant4 096

Legend: ✅ = supported in this build, ❌ = platform can't do it, — = not wired yet (follow-up PR). Slack's typing cell is empty because the modern Slack Web / Events API doesn't expose a bot typing trigger (the old RTM API that did is deprecated).

"Rich" covers provider-native rich-message publishing beyond the shared text/attachments surface — today this is Slack Block Kit, ephemeral (chat.postEphemeral), scheduled (chat.scheduleMessage), and metadata via the provider_options.slack tunnel on send_message. Other providers either don't expose an equivalent (WhatsApp / Signal / HomeAssistant / webhook-mode Slack) or haven't been wired yet (Telegram's inline-keyboard reply markup, Discord components, email HTML body) — see the slack page for the concrete API.

Slack integration modes. The matrix shows the recommended bot-token mode (full Slack Web API — chat.postMessage, chat.update, files.v2, reactions.add, Events API webhook). Minara also supports a simpler webhook-URL mode for deployments that can't install a Slack app — it's a narrower capability subset (no streaming, no file uploads, no reactions, no typing, no ephemeral / scheduled) but still supports plain text, Block Kit blocks, and threaded replies. See the Slack page for both setup paths and the mode-specific capability breakdown.

Home Assistant shows all ❌ for a similar structural reason — its notify.<service> API is a text-only sink across every concrete notify platform we've surveyed. If you need an image attached, put it on a CDN and include the URL in the message body.

"Streaming" means the agent's token-by-token response is shown as a single message that gets edited in place. Providers without streaming buffer the full response and send once at finalize — via the shared createStreamSink helper in apps/agent/src/messaging/stream-helpers.ts.

Attachments

send_message({attachments: [...]}) can attach images, documents, or voice notes that the agent has generated inside its sandbox (via image_generate, audio_generate, write_file, code execution, etc.). The LLM references them by their sandbox-relative path:

send_message({
  provider: "telegram",
  text: "BTC/USD daily with key levels",
  attachments: [
    { kind: "image", sandbox_path: "images/btc-2026-04-19.png" },
    { kind: "file", sandbox_path: "files/levels.csv", caption: "CSV of levels" },
  ],
})

Attachment kinds:

  • image — for images. Routes to provider's image-optimized endpoint (Telegram sendPhoto, WhatsApp image, etc.).
  • file — generic document attachment. Used for PDFs, CSVs, archives.
  • voice — short voice note. Telegram requires OGG/Opus; the resolver surfaces a clear error for non-OGG voice sends.
  • audio — music / podcast / long audio. Telegram sendAudio; treated like voice on providers without a dedicated voice UI.

Security posture:

  • Only sandbox paths are accepted — the resolver rejects .. escapes, absolute paths, and symlinks that leave the sandbox before any byte is uploaded to a provider. An attacker cannot use send_message as an exfiltration channel.
  • 50 MB per-attachment cap (configurable via MESSAGING_MAX_ATTACHMENT_BYTES). Provider APIs enforce their own maxima independently.
  • Provider support is capability-gated: sending a kind the resolved provider doesn't advertise (e.g. voice to WhatsApp) returns a clear error at the tool boundary, not a 400 at the API layer.

Threads

Pass thread to send_message to post into a threaded conversation:

send_message({
  provider: "slack",
  text: "follow-up",
  thread: "1700000000.000100",  // parent message's ts
})

Per-provider semantics (automatic; callers just pass thread):

  • Telegram: message_thread_id for forum topics (supergroups + private chats).
  • Discord: thread is a channel — the thread id replaces the channel id in the URL. Works for both new and archived threads.
  • Slack: thread_ts — the parent message's timestamp. Bot mode only (webhook mode is rejected).
  • Email: the value becomes both the In-Reply-To and References headers. Pass the parent email's Message-ID (usually angle-bracketed, e.g. <abc@host>).

Providers without thread support (whatsapp, signal, home_assistant) return a clear error at the tool boundary if you pass thread.

Typing indicators + reactions

Two additional tools — set_typing and add_reaction — are available for in-conversation feedback. They're tier-2 CONFIRM_ONCE (ornamental signals, not egress), separate from send_message's tier-3 confirmation.

// Let the user know the bot is thinking before a long reply.
set_typing({ provider: "telegram", on: true })

// Acknowledge an inbound message with an emoji instead of composing text.
add_reaction({
  provider: "discord",
  message_id: "1234567890",
  emoji: "👍",
})

Typing persistence: Telegram and Discord indicators expire after ~5–10 seconds; the typing-heartbeat helper in apps/agent/src/messaging/typing-heartbeat.ts refires automatically — use it when you want "typing…" to persist through a long LLM turn.

Capability support (see matrix above): typing is supported on telegram / discord / signal; reactions on discord / slack (bot) / signal. Other providers reject both at the tool boundary.

Inbound messages: two-way chat

Minara can also receive messages and reply, so you can talk to the agent directly from a chat app instead of the CLI. There are two ways a message reaches the agent, and which one a platform uses decides whether two-way chat works on a machine with no public IP.

Client-outbound daemons (the default)

For most platforms the agent connects out and holds a long connection (HTTP long-poll or WebSocket), and messages stream down it. The agent is the client, so this works behind NAT, on a personal laptop, with no public address, no tunnel, and no third party. This is the default: each daemon auto-starts when the platform's outbound credentials are set and no public webhook is configured for it. Override per platform with the MESSAGING_<PLATFORM>_* switches (see env vars); the switch is tri-state (unset = auto, 1 = force on, 0 = force off).

Platforms with a client-outbound daemon: Telegram (getUpdates), Discord (Gateway), Slack (Socket Mode), Mattermost (v4 WebSocket), QQ (v2 gateway), DingTalk (Stream Mode), Lark (long connection), plus Matrix (/sync) and Signal (signal-cli).

Webhook listener (when a platform requires it)

Some platforms only deliver inbound by POSTing to a public HTTPS endpoint. For those, Minara runs an HTTP webhook server (see apps/agent/src/messaging/inbound/server.ts), gated by MESSAGING_INBOUND_ENABLED. It binds 127.0.0.1 by default, so a no-public-IP host needs a reverse proxy or tunnel that terminates TLS and forwards to it. Setting a platform's webhook signing secret also switches that platform back to webhook inbound even when it has a daemon.

Security posture:

  • Every request is signature-verified before dispatch (Telegram's X-Telegram-Bot-Api-Secret-Token, Slack's HMAC-SHA256 over v0:{ts}:{body}, Discord's and QQ's Ed25519, Lark's AES envelope, JWT for Teams and Google Chat). Unverifiable requests return 401.
  • 5-minute replay window on timestamped schemes.
  • Body size cap (4 MB default); larger bodies return 413.

Reachability: which platforms go fully local

Inbound modelPlatformsTwo-way chat with no public IP
Client-outbound daemonTelegram, Discord, Slack, Mattermost, QQ, DingTalk, Lark, Matrix, SignalYes, no tunnel needed
Webhook only (platform connects in)WhatsApp, LINE, WeCom, WeChat OA, TeamsNo, needs a public webhook (tunnel / reverse proxy)
Send-only (no inbound)Email, Gmail, Home AssistantOutbound notifications only

Google Chat (Cloud Pub/Sub pull) and BlueBubbles (a socket to the self-hosted server) can also go client-outbound; they currently ship with webhook inbound.

Voice transcription: when MESSAGING_INBOUND_TRANSCRIBE=1 and OPENAI_API_KEY is configured, inbound voice messages are transcribed via OpenAI Whisper before dispatch. The transcription populates InboundMessage.text while the original audio stays in attachments for replay.

How Minara uses messaging

Once a provider is configured, three things can send to it:

1. The send_message tool — from inside the agent

When the LLM decides a notification is warranted, it calls:

send_message({
  provider: "telegram",
  text: "BTC drawdown 5% triggered the watch",
})

The agent reaches for this mid-conversation — e.g. "alert me when ETH breaks $4 000 on Telegram" sets up a scheduled workflow that calls send_message when the condition fires.

2. Autopilot — trade execution reports

Autopilot (when enabled) emits a summary after each execution:

🟢 Bought $100 of SOL @ $167.23
   Position: +$100 | Slippage: 0.04% | Gas: $0.12
   Reason: momentum > 3σ on 1h chart

These go to whichever provider is set as your default notification target (configured in safety.json).

3. Workflows — cron-based alerts

Scheduled monitoring runs in the background with read-only + messaging permissions:

you: watch the top 20 tokens by 24h volume, alert me on >5% moves every 15 min

agent: [sets up a cron workflow with tool set "read, memory, messaging"]

The workflow can observe and notify — but the allowlist blocks it from trading, even if the LLM changes its mind mid-run.

Overriding the target channel

send_message accepts a channel override, so a single provider can fan out to multiple destinations:

send_message({
  provider: "telegram",
  channel: "-1009876543210",    // different chat from the default
  text: "Critical: position liquidation imminent",
})

Useful for routing urgent alerts to a separate phone / group while keeping routine notifications on the default channel.

Security posture

  • Credentials live in ~/.minara/env — outside the repo, outside the project directory
  • minara gateway list redacts secrets — you see 12***xyz (46 chars), not the raw token
  • Messaging is tier 3 (ALWAYS_CONFIRM) for interactive calls — every send_message call from the REPL prompts for confirmation. Autonomous turns (cron / autopilot) can run tier-3 tools only when safetyConfig.autopilotEnabled is set; otherwise messaging is refused on those paths. Credential writes via minara gateway add happen inside an interactive wizard (no separate tool-level confirm prompt — the wizard itself is the prompt)
  • Messaging cannot execute trades — the tool-set allowlist separates "can look and notify" from "can trade". A messaging-enabled workflow doesn't have the fund-moving tools even if the LLM tries to use them

Next

Pick a platform above and walk the setup. Telegram is the easiest — fewest moving parts, the streaming-capable gateway is most battle- tested, and the test loop (/newbot → chat id → minara gateway test telegram) takes under three minutes end to end.

On this page