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:
- Lark / Feishu, tenant token + optional AES-256 webhook
- WeCom (企业微信), SHA1 sort + AES envelope
- DingTalk (钉钉), HMAC-SHA256 signed URL robot
- Microsoft Teams, Bot Framework with JWT-validated inbound
- Google Chat, service-account JWT auth
- Mattermost, self-hosted, bot token + outgoing webhook
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:
- Matrix, Client-Server API + long-poll daemon (no E2EE)
- BlueBubbles (iMessage), iMessage bridge via self-hosted Mac
- WhatsApp, Meta Cloud API; send-only, E.164 recipient
Notifications & non-chat:
- Email, SMTP; send-only, subject auto-split
- Signal, via local
signal-clisubprocess; 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 TelegramFor 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 credentialsRunning 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 credentialsThe 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/Disconnectbuttons. - The
Testbutton sends an actual ping — you see the message in the IM/email client and the row tags itselflast 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:
step.provider(explicit per-step).definition.delivery.provider(workflow-level default).- The single connected provider, when exactly one is connected.
- Otherwise → activation refused with a structured error pointing
you at
/connect <platform>(REPL) orSettings → 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.
| Provider | Stream | Images | Files | Voice | Threads | Typing | Reactions | Rich | Inbound | Char limit |
|---|---|---|---|---|---|---|---|---|---|---|
telegram | ✅ | ✅ | ✅ | ✅ (OGG) | ✅ | ✅ | — | — | ✅ | 4 096 |
discord | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | 2 000 |
slack | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | 40 000 |
email | ❌ | ✅ | ✅ | ❌ | ✅ | — | — | — | — | 1 000 000 |
whatsapp | ❌ | ✅ | ✅ | ❌ | — | — | — | — | — | 4 096 |
signal | ❌ | ✅ | ✅ | ❌ | — | ✅ | ✅ | — | — | 4 096 |
home_assistant | ❌ | ❌ | ❌ | ❌ | — | — | — | — | — | 4 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 (TelegramsendPhoto, WhatsAppimage, 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. TelegramsendAudio; treated likevoiceon 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 usesend_messageas 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
kindthe resolved provider doesn't advertise (e.g.voiceto 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_idfor 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-ToandReferencesheaders. Pass the parent email'sMessage-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 overv0:{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 model | Platforms | Two-way chat with no public IP |
|---|---|---|
| Client-outbound daemon | Telegram, Discord, Slack, Mattermost, QQ, DingTalk, Lark, Matrix, Signal | Yes, no tunnel needed |
| Webhook only (platform connects in) | WhatsApp, LINE, WeCom, WeChat OA, Teams | No, needs a public webhook (tunnel / reverse proxy) |
| Send-only (no inbound) | Email, Gmail, Home Assistant | Outbound 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 chartThese 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 listredacts secrets — you see12***xyz (46 chars), not the raw token- Messaging is tier 3 (
ALWAYS_CONFIRM) for interactive calls — everysend_messagecall from the REPL prompts for confirmation. Autonomous turns (cron / autopilot) can run tier-3 tools only whensafetyConfig.autopilotEnabledis set; otherwise messaging is refused on those paths. Credential writes viaminara gateway addhappen 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.