Skip to content

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

  1. Go to https://api.slack.com/apps and click Create New App.
  2. Choose From scratch, give the app a name, and select your workspace.
  3. Under OAuth & Permissions add the chat:write and (optionally) chat:write.public bot token scopes.
  4. Install the app to your workspace and copy the Bot User OAuth Token (xoxb-...).
  5. 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

  1. Open Telegram and start a chat with @BotFather.
  2. Send /newbot, choose a display name and a @username.
  3. 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:

https://api.telegram.org/bot<TOKEN>/getUpdates

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.

  1. In Discord, go to Server Settings > Integrations > Webhooks and create a new webhook for the target channel. Copy the webhook URL.
  2. Store the URL as a MAC secret:
    mac admin secret set discord-webhook-url --from-file /path/to/webhook-url \
      --created-by <human-id> --scopes '{"agents": ["<hub-agent-id>"]}'
    
  3. Configure a hermes notifier channel that targets the hub agent; the hub agent's system prompt or a cron skill is responsible for forwarding status_update messages 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 Q7 is dropped there too), or
  • post a channel message that starts with the code: Q7 blue or q7: 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, and mac 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 kind report);
  • any coding agent's human-reports.jsonl in 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:

  1. If the notification's metadata.actor resolves to a known agent with a hermes_instance_id, the service targets all platform bindings on that Hermes instance (Slack + Telegram only).
  2. 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

mac notification list --status failed --limit 20
mac notification show <notification-id>

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:

mac admin observability events --name notifier.delivery_failed --limit 10

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"}'
Check "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:

curl "https://api.telegram.org/bot<TOKEN>/sendMessage" \
  -d chat_id=<CHAT_ID> \
  -d text="test"
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:

mac service status notifier

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:

MAC_LOG_LEVEL=debug mac admin notifier deliver --once

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")