Notifier Configuration Guide¶
This guide explains how to configure the MAC fleet's notification delivery system, set up platform bindings for Slack, Telegram, and Discord webhooks, define event-to-platform routing rules, customise message templates, and diagnose delivery failures.
Overview¶
NotifierService is the delivery bridge between MAC's internal task-progress
events (operator_notifications rows) and the human-facing channels your team
monitors. Every time a task completes, fails, or advances to a review state the
hub writes a notification record. The notifier service drains those records and
routes each one to one or more Hermes agents that then forward the message to
the correct Slack channel, Telegram chat, or other platform.
Key concepts:
| Term | Meaning |
|---|---|
| Notifier channel | A named routing rule: which event types go to which agent/binding |
| Platform binding | A link between a Hermes instance and an external chat identity (Slack channel, Telegram chat, etc.) |
| Hermes instance | A running Hermes process identified by its instance ID |
| Agent | A MAC worker agent; its hermes_instance_id links it to a Hermes instance |
Delivery is pull-based: a background tick calls deliver_pending() on a
schedule, claims notifications atomically, and marks them delivered or
failed. Stale claims (worker crashed mid-delivery) are automatically
reclaimed after a configurable timeout (default 600 s).
Supported Channel Types¶
| Type | Delivery mechanism |
|---|---|
hermes |
Routed through a Hermes agent's send_message tool |
slack |
Routed through a Hermes agent that has a Slack platform binding |
telegram |
Routed through a Hermes agent that has a Telegram platform binding |
1. Slack Setup¶
1.1 Create a Slack App¶
- Go to https://api.slack.com/apps and click Create New App.
- Choose From scratch, give the app a name, and select your workspace.
- Under OAuth & Permissions add the
chat:writeand (optionally)chat:write.publicbot token scopes. - Install the app to your workspace and copy the Bot User OAuth Token
(
xoxb-...). - Invite the bot to the target channel:
/invite @<bot-name>.
1.2 Register the Platform Binding¶
mac platform-binding create \
--hermes-instance-id <instance-id> \
--platform slack \
--external-id C0123456789 \ # Slack channel ID
--display-name "#ops-alerts"
The external_id is the Slack channel ID (not the name). Find it by right-
clicking the channel in Slack and choosing "Copy link" — the last path segment
is the channel ID.
1.3 Configure the Notifier Channel¶
mac admin notifier configure \
--name ops-slack \
--channel-type slack \
--event-types "task.completed,task.failed" \
--target '{"platform_binding_id": "<binding-id>"}'
Or via the Python API:
cp.notifiers.configure_channel(
"ops-slack",
"slack",
event_types=["task.completed", "task.failed"],
target={"platform_binding_id": "<binding-id>"},
)
2. Telegram Setup¶
2.1 Create a Bot via BotFather¶
- Open Telegram and start a chat with
@BotFather. - Send
/newbot, choose a display name and a@username. - Copy the API token (
123456:ABC-DEF...).
2.2 Obtain the Chat ID¶
Forward any message from the target group/channel to @userinfobot to get the
numeric chat ID. For private channels, use the Telegram Bot API:
Send a message to the group first, then check the JSON response for
message.chat.id.
2.3 Register the Platform Binding¶
mac platform-binding create \
--hermes-instance-id <instance-id> \
--platform telegram \
--external-id "-1001234567890" \ # numeric chat ID (negative for groups/channels)
--display-name "ops-channel"
2.4 Configure the Notifier Channel¶
cp.notifiers.configure_channel(
"ops-telegram",
"telegram",
event_types=["task.*"], # wildcard: all task events
target={"platform_binding_id": "<binding-id>"},
)
3. Discord Webhook Setup¶
Discord does not have a native hermes platform integration; use an incoming
webhook URL delivered through a hermes channel type.
- In Discord, go to Server Settings > Integrations > Webhooks and create a new webhook for the target channel. Copy the webhook URL.
- Store the URL as a MAC secret:
- Configure a
hermesnotifier channel that targets the hub agent; the hub agent's system prompt or a cron skill is responsible for forwardingstatus_updatemessages to the Discord webhook.
4. Event-to-Platform Routing Rules¶
4.1 Event Pattern Syntax¶
The event_types field on a notifier channel accepts:
| Pattern | Matches |
|---|---|
task.completed |
Exactly the task.completed event |
task.* |
Any event whose type starts with task. |
| (empty list) | Defaults to all events starting with task. |
Multiple patterns can be combined. A notification matches the channel if any pattern matches its event type.
4.2 Routing by Channel Type¶
When a notification carries channels: ["slack"], only notifier channels with
channel_type = "slack" (and optionally "hermes") are considered. When
channels: ["hermes"] the service considers all enabled channels and also falls
back to auto-discovery (see Section 5).
4.3 Example: Separate Alert Tiers¶
# Critical failures -> ops-team Telegram immediately
cp.notifiers.configure_channel(
"critical-telegram",
"telegram",
event_types=["task.failed", "task.error"],
target={"platform_binding_id": "<telegram-binding>"},
)
# All completions -> low-priority Slack digest channel
cp.notifiers.configure_channel(
"completions-slack",
"slack",
event_types=["task.completed"],
target={"platform_binding_id": "<slack-binding>"},
)
# Everything -> hub agent for structured logging
cp.notifiers.configure_channel(
"all-hermes",
"hermes",
# event_types omitted -> defaults to task.*
target={"agent_id": "<hub-agent-id>"},
)
4.4 Questions: answering from Slack¶
An agent's question (task.question) is the one notification a person replies
to. Subscribe a channel to it:
mac admin notifier configure \
--name questions-slack \
--channel-type slack \
--event-types task.question \
--target '{"agent_id": "<hub-agent-id>"}'
The worker behind that agent posts each question to its Slack home channels
with a short code, for example *Q7* Answer needed: .... A person answers in
either of two ways:
- reply in the question's thread (a leading
Q7is dropped there too), or - post a channel message that starts with the code:
Q7 blueorq7: blue.
The first answer is recorded on the task board as the answer, authored by the person's Slack display name. If the task is parked on that question, the answer returns it to the queue. The worker then posts a one-line receipt in the thread. A later reply is kept on the board as a plain message. Messages from bots, including Hermes, are ignored, and so are channel messages without a code.
The Slack text leaves out the mac task answer command that the notification
body carries for operators. In a channel Hermes reads, that line invited Hermes
to answer for the person, which bypassed the board.
Hermes still answers every message in a free-response channel, so each agent
would otherwise reply to an answer meant for MAC. scripts/fleet-update installs
a Hermes plugin, mac-question-gate (mac.hermes_question_gate), on every host.
It drops a coded channel message and any reply in a question's thread before the
agent sees it. A message that @-mentions someone still goes through, so a person
can pull an agent into a question thread. The same step regenerates the agent's
MAC runtime context, so runtime rules reach Hermes on every update. Hermes loads
both on restart, so roll the change out with fleet-update --hermes.
The worker stops watching a question when the question is answered anywhere,
including on the board or in another workspace, when the task closes, or after
14 days. It polls every MAC_WORKER_CHAT_POLL_SECONDS (default 30). This loop
also drains the agent's mailbox, so questions go out while the worker is busy
with a long task.
The bot token needs the channels:history scope (groups:history for a private
channel) to read the thread, and users:read for display names. Without
users:read, the answer is authored by the Slack user ID.
Only an admin, or the agent the notifier sent the question to, may relay a reply
(POST /notifications/<id>/replies). Anyone who can post in the channel can
answer, so subscribe questions only to channels whose members you would let
answer the agents.
Telegram questions are not relayed yet: the worker does not post to Telegram.
4.5 Agent reports: act, then tell¶
Agents keep their authority, but they report to people anything a person
might want to know: what they did beyond their own branch and pull request
(repository settings, rulesets, force-pushes, host changes, external side
effects), and what they saw another agent do (a peer report). Each report is
one agent.report notification and an agent.report.filed observability
event. It is written when filed, so it is delivered even if the reporting task
fails later. A repeated report (same dedupe key) notifies nobody twice.
Reports arrive from four places:
- the hub API,
POST /reports, andmac agent report "..." --why --undo [--about-agent A --about-task T] [--task T]; - the MCP tool
mac_report; - a Claude Code task agent's
.mac-agent/board report "..."(a board message of kindreport); - any coding agent's
human-reports.jsonlin its task workspace, which the worker files when the attempt ends, whatever its outcome.
The worker also files one itself when a GitHub repository's settings,
rulesets, default-branch protection or webhooks changed during a task attempt.
It snapshots them before and after the attempt; MAC_REPO_ADMIN_WATCH=0
turns this off.
To reach Slack, a channel must subscribe to agent.report. The simplest
setup adds it to the channel that already receives questions:
mac admin notifier configure questions-slack slack \
--event-types "task.question,agent.report" \
--target '{"agent_id": "<hub agent with the Slack home channel>"}'
5. Auto-Hermes Fallback¶
When a notification carries channels: ["hermes"] and no configured
notifier channel matches it, the service falls back to auto-discovery:
- If the notification's
metadata.actorresolves to a known agent with ahermes_instance_id, the service targets all platform bindings on that Hermes instance (Slack + Telegram only). - Otherwise, it fans out to all Slack and Telegram platform bindings registered on the fleet.
The fallback is intentionally broad so no notification is silently dropped when
channels have not been configured yet. Suppress it by disabling the hermes
channel in the notification or by registering an explicit notifier channel.
6. Template Customisation¶
Message payloads use the schema mac.notifier.task_progress.v1:
{
"schema": "mac.notifier.task_progress.v1",
"status": "<event_type>",
"notification": {
"id": "...",
"event_type": "task.completed",
"subject_type": "task",
"subject_id": "task_...",
"title": "Task completed: ...",
"body": "...",
"channels": ["hermes"],
"metadata": {},
"status": "delivered",
"created_at": "...",
"delivered_at": "..."
},
"channel_type": "hermes",
"target": {}
}
The Hermes agent that receives the STATUS_UPDATE message is responsible for
rendering it to the destination platform. Edit the agent's system prompt or a
cron skill to control formatting (Markdown, block-kit, etc.).
Security: where containment actually happens¶
Notification payloads are delivered as written. Keys are not filtered, renamed, or inspected by name.
Earlier versions rewrote any key spelled like an execution verb -- command
became command_text -- to satisfy a filter in the message channel. Both the
filter and the renaming are gone. They predate OpenShell, and containment is
enforced by the sandbox: which commands an agent may run, and which endpoints
it may reach. A payload key is data, and nothing on the delivery path executes
it, so refusing one by its spelling protected nothing while corrupting the
notification a human reads to understand what happened.
Payloads are still validated for required fields per message type and for JSON serializability. Those are contracts, not guards.
7. Debugging Delivery Failures¶
7.1 Inspect Notification Status¶
Status values:
| Status | Meaning |
|---|---|
pending |
Not yet attempted |
delivering |
Claimed by a worker; in progress |
delivered |
At least one message sent successfully |
skipped |
No matching targets (check channel config) |
failed |
Delivery raised an exception |
7.2 Check Observability Logs¶
Delivery failures emit a notifier.delivery_failed observability event:
Successful deliveries emit notifier.delivered with the message IDs.
7.3 Validate Webhook Credentials¶
Slack: Test with curl:
curl -X POST https://slack.com/api/chat.postMessage \
-H "Authorization: Bearer xoxb-..." \
-H "Content-Type: application/json" \
-d '{"channel": "C0123456789", "text": "test"}'
"ok": true in the response. Common errors:
- not_in_channel — invite the bot to the channel first.
- token_revoked — regenerate the token in the Slack app dashboard.
- channel_not_found — use the channel ID (starts with C), not the name.
Telegram: Test with:
Common errors: -400 Bad Request: chat not found — bot not a member of the group; add it.
- 403 Forbidden — bot was kicked; re-add.
7.4 Rate Limits¶
Slack: Tier-3 endpoints allow ~50 requests/minute per workspace. If the
fleet fires many notifications in a burst, some deliveries will receive 429
Too Many Requests. The notifier marks these failed; the stale-claim retry
(default 600 s) will re-attempt them automatically.
Telegram: Global limit of 30 messages/second; per-group limit of 20 messages/minute. For high-volume fleets consider routing to a single hub agent that batches messages.
7.5 Stale "delivering" Records¶
If a worker crashes between claiming and delivering, the notification stays
delivering indefinitely. The service automatically reclaims records whose
delivered_at is older than DELIVERY_CLAIM_TIMEOUT_SECONDS (600 s by
default) on the next deliver_pending() call. No manual intervention is
required; however, if you see persistent delivering rows older than 10
minutes, check that the notifier background tick is running:
7.6 Skipped Notifications¶
A notification is skipped when deliver_pending() finds no targets. This
means either:
- No notifier channel is configured that matches the event type — add one.
- The channel's target specifies an agent ID that has no hermes_instance_id.
- All matching channels are disabled (enabled=False).
- The platform binding's Hermes instance has no registered agents.
Enable verbose logging to trace target resolution:
8. Quick Reference¶
from mac.services import ControlPlane
cp = ControlPlane.in_memory() # or load from config in production
# Create a Slack notifier channel
ch = cp.notifiers.configure_channel(
"ops-slack",
"slack",
event_types=["task.completed", "task.failed"],
target={"platform_binding_id": "<binding-id>"},
enabled=True,
)
# List all enabled channels
for c in cp.notifiers.list_channels(enabled=True):
print(c.name, c.channel_type, c.event_types)
# Trigger delivery manually (normally runs on a schedule)
result = cp.notifiers.deliver_pending(limit=100)
print(result["delivered"], "delivered,", result["failed"], "failed")
# Disable a channel temporarily
cp.notifiers.configure_channel("ops-slack", "slack", enabled=False)
# Delete a channel
cp.notifiers.delete_channel("ops-slack")