Notification Channels
AILANG's notification framework (internal/notify) delivers outbound
notifications — "a design doc is awaiting approval", "a task failed" — to wherever
you'll actually see them. It is the Go port of the design behind Aitana's v6
Channels Framework: a tiny Channel interface plus a Registry that the
notification daemon uses as an output router.
Status (v0.24.0): outbound-only. The macOS desktop notifier and a Discord webhook channel ship today. Inbound ("reply approve to apply the
design-approvedlabel") is a planned follow-up. See m-notify-channels-framework.
Concepts
Notification— the transport-independent payload (Title,Subtitle,Body,URL, plus macOS-onlySound/Group). Channels render whichever fields they support; the rest are ignored.Channel— an outbound transport:Name() string+Send(ctx, Notification) error.Registry—name → Channel, used as the output router (registry.Get("discord").Send(...)).Registeris idempotent for the same instance and errors on a different instance for the same name (catches double-registration bugs).- Fail-closed registration — a channel registers only if its secret is present. No secret → not registered → the daemon still boots, just without that output.
Channels that ship today
| Channel | Name | Secret | Notes |
|---|---|---|---|
| macOS desktop | macos | none (host capability) | via terminal-notifier/osascript; no-op off Darwin |
| Discord | discord | AILANG_DISCORD_WEBHOOK_URL | incoming webhook; chunked at 2000 chars |
Enabling Discord
-
In your Discord server: Channel → Settings → Integrations → Webhooks → New Webhook, copy the URL.
-
Provide the URL where the daemon runs (treat it as a secret — anyone with the URL can post). The daemon resolves it in this order, and logs which source it used:
Order Source Use it for 1 AILANG_DISCORD_WEBHOOK_URLan explicit, per-host override 2 Secret Manager — <prefix>-discord-webhook-urlthe fleet default: one value every machine resolves identically 3 macOS login Keychain a host with no cloud access Secret Manager (recommended — the fleet source of truth):
# once per environment; the terraform in ailang-multivac creates the secretprintf '%s' 'https://discord.com/api/webhooks/…' | \gcloud secrets versions add ailang-discord-webhook-url \--project ailang-multivac --data-file=-The secret is
${prefix}-discord-webhook-url, soailang-discord-webhook-urlin prod andailang-dev-discord-webhook-urlin dev. The daemon reads thelatestversion with a 5s bound at registration; a read that fails is logged naming the secret and project, then falls through to the Keychain. No IAM is needed while the daemons authenticate with ADC for an account that isroles/owner; grantroles/secretmanager.secretAccessoron the secret if that becomes a service account.macOS login Keychain (fallback — read the warning below):
security add-generic-password -U -A -a "$USER" -s ailang-discord-webhook -w 'https://discord.com/api/webhooks/…'The daemon runs as a user LaunchAgent and reads it via
security find-generic-password -s ailang-discord-webhook -w. Update the value any time by re-running the command (the-Uflag overwrites).The LaunchAgent must load into the Aqua session, or this read fails. The login Keychain is only reachable from the GUI login session. A daemon loaded into the background domain gets
errSecInteractionNotAllowedeven though the item is present and its ACL is wide open (-Aabove). The plist generated byailang daemon installsetsLimitLoadToSessionType=Aquafor this reason — a hand-written or hand-edited plist that omits it will look correctly configured and silently have no Discord.Aqua is necessary but NOT sufficient: the login Keychain is also locked whenever a DIFFERENT user holds the console. On a shared machine this is the sharper trap, because the daemon looks correctly configured and the Keychain item is present and valid.
Measured on the rig 2026-09-30: the webhook item had been in the Keychain since 2026-05-28 and Discord posts had stopped anyway.
security find-generic-password -s ailang-discord-webhook -wexited 36 (errSecInteractionNotAllowed= -25308, mod 256) printing nothing at all — no error text, empty stdout.LimitLoadToSessionTypewas not the cause there:launchctl print gui/501/com.sunholo.ailang.daemonshowed the job already running indomain = gui/501. The cause was that/dev/consolewas owned bydaneelwhile the daemon ran asvoightkampff— three users had console sessions and voightkampff's was not the active one, so its login Keychain was locked to background processes.security show-keychain-infoon it returnedUser interaction is not allowedtoo.So on any multi-user or fast-user-switched host, do not use the login Keychain for this secret. Use the env var below (and
chmod 600whatever file carries it), or the System keychain, which is unlocked at boot and is already on the default search list — neither depends on who holds the console.Or via env var (any host; takes precedence over the Keychain):
export AILANG_DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/…" -
The channel registers automatically on next daemon start. No source set → not registered (macOS-only), and the log names every place that was tried.
Registration is fail-closed and quiet, so verify it rather than assuming. A daemon that cannot read the secret still boots; it logs one line and carries on with macOS notifications only. Check which channels actually registered:
ailang daemon status # or:grep -a "channels=\[" /tmp/ailang-daemon.log | tail -1channels=[macos discord]is healthy.channels=[macos]paired withnotify: discord channel not registered (AILANG_DISCORD_WEBHOOK_URL unset and not in keychain)means the daemon could not read the secret — note that the message says "not in keychain" for an unreadable item exactly as it does for a missing one, so confirm which it is before re-adding the webhook:security find-generic-password -s ailang-discord-webhook >/dev/null 2>&1 \&& echo "item EXISTS" || echo "item MISSING"security find-generic-password -s ailang-discord-webhook -w >/dev/null 2>&1echo "read exit=$?" # 0 = readable, 36 = present but not readable here
Live check:
AILANG_DISCORD_WEBHOOK_URL='…' go test ./internal/notify/ -run TestDiscordLive -count=1 -vsends a real test message through the production path.
Adding a new channel (~1–2h)
Mirror internal/notify/discord.go:
-
Implement
Channelininternal/notify/<name>.go:type SlackChannel struct {webhookURL stringhttp httpDoer // injected so tests need no network}func (c *SlackChannel) Name() string { return "slack" }func (c *SlackChannel) Send(ctx context.Context, n Notification) error {for _, chunk := range chunkMessage(render(n), slackLimit) {if err := c.post(ctx, chunk); err != nil {return err // typed error -> caller dead-letters}}return nil}Handle your transport's length limit with
chunkMessage. Return a typed error on transport failure or any non-2xx response. -
Gate registration on a secret in
internal/notify/register.go:if url := os.Getenv("AILANG_SLACK_WEBHOOK_URL"); url != "" {if err := reg.Register(NewSlackChannel(url)); err == nil {registered = append(registered, "slack")}}Never construct a channel that panics on a missing secret — gate at the registration site so a host without the secret still boots.
-
Test with no network — inject a fake
httpDoer; cover the happy path (assert URL + body + chunk count), a non-2xx response, and a transport error. The cross-channel contract test (smoke_test.go) enrolls every registered channel automatically (non-emptyNamematching its registry key).
Routing
The daemon stamps every Notification with an EventType (pending_approval,
completed, failed, public-feedback, message) and the fan-out asks each
channel whether it accepts the type. The macOS channel implements no filter and
takes everything (passive desktop feed); Discord ships with a curated allow-list
so the phone only buzzes for things that need your attention.
Default Discord filter
DefaultDiscordEventTypes = ["pending_approval", "failed", "public-feedback"]
So out of the box: ⏳ approval-needed, ❌ task-failed, and 🌐 external-feedback ping the phone. ✅ task-done and routine ✉️ inbox messages do not — they still land on macOS.
Overriding the filter
// All-quiet remote (still register the channel for visibility, but no pings):
discord.SetEventTypes([]string{})
// Pager-grade: only the truly urgent
discord.SetEventTypes([]string{"failed"})
// Inverse — accept everything (revert to firehose):
discord.SetEventTypes(nil)
A channel that implements EventFilter and rejects an event is skipped by
the fan-out — it does not count toward the remote-authoritative ack quota and
does not trigger a retry. So filtering doesn't break the "ack if any remote
delivered" policy: if all remote channels filter out the event, the daemon
falls back to local-best-effort and acks on macOS success.
Dead-lettering persistent failures
internal/notify/registry.go::SendAll returns an error when every accepting
remote channel fails. The daemon nacks → Pub/Sub redelivers. To prevent infinite
retry on a permanently-broken channel (e.g. a revoked Discord webhook), configure
subscription-level dead-lettering on ailang-{env}-messages-laptop and
ailang-{env}-events-laptop, routing to the existing ailang-{env}-dead-letter
topic after N attempts:
gcloud pubsub subscriptions update ailang-dev-messages-laptop \
--project=ailang-multivac-dev \
--dead-letter-topic=ailang-dev-dead-letter \
--max-delivery-attempts=5
(Same for ailang-dev-events-laptop.) Pub/Sub does the routing for us — no
in-daemon dead-letter publishing code is needed.