Notification system

July 26, 2026 · View on GitHub

The notification subsystem lets the voice agent attempt delivery outside the current Gemini reply path. Delivery is best-effort: a queued voice send or webhook enqueue is not proof that a person received the message.

Delivery modes

notification.py:deliver(text, mode, ...) accepts six modes:

ModeCurrent behaviorImportant boundary
voiceQueues bridge._gemini.send_text() when the Gemini bridge reports _runningqueued: true confirms scheduling on the event loop, not spoken playback or user presence
dmSends a Discord DM through the bot adapter when the target user is already available from the Discord client cacheA cache miss returns an error; the implementation does not fetch an uncached user
channelPosts to a configured Discord text channelRequires a live adapter and a resolvable channel ID
webhookEnqueues an agent.notify event through WebhookDispatcherThe returned URL count is an enqueue/target count, not confirmed remote delivery; see Issue #11
autoTries voice, then DM, then channel, then webhookA successful voice queue also attempts a webhook side effect; that webhook result is not included in the returned voice result
allCalls voice, DM, channel, and webhook sequentially and returns every resultThe channels are not fired concurrently despite the mode name

Gemini tools

local_notify

Example input:

{
  "text": "The delegated task finished",
  "mode": "auto",
  "title": "Task complete",
  "source": "health_watcher"
}

The tool returns the direct deliver() result under result. Typical successful shapes include:

{
  "result": {
    "status": "ok",
    "channel": "voice",
    "queued": true
  }
}

For all, the response contains a channels object with one result per attempted path. Do not interpret status: "ok" from voice or webhook as an end-user delivery receipt.

local_notify currently falls back to a repository-embedded Discord user ID when neither a live target nor DISCORD_VOICE_LIVE_USER_ID is available. The shared executable identity fallback and fail-closed recipient boundary are tracked canonically in Issue #18; scheduled persistence and restart routing are tracked separately in Issue #13. Treat DM routing as trusted single-user behavior until both boundaries are resolved.

local_notify_schedule

Example input:

{
  "text": "Reminder: standup in 10 minutes",
  "delay_seconds": 600,
  "mode": "auto"
}

The tool accepts either delay_seconds or fire_at_epoch. A successful schedule call returns an ID, absolute fire time, and remaining seconds. The queue file is:

~/.hermes/voice-scheduled-notifications.jsonl

The scheduler polls every two seconds.

Other actions on the same tool:

  • {"list": true} lists persisted entries.
  • {"cancel_id": "n-..."} removes one entry.

Current scheduling blocker

Scheduled notifications are not currently reliable enough to describe as restart-safe:

  • the tool passes live bridge and Discord adapter objects into the JSON persistence layer, which can make scheduling fail because those objects are not serializable;
  • persisted entries cannot restore those runtime objects after restart;
  • every due entry is removed after one attempt, including exceptions and unsuccessful delivery results;
  • recipient fallback is not explicit.

See Issue #13. Until it is fixed, verify that the schedule entry was actually created and do not rely on this path for urgent, safety-critical, or one-shot reminders.

Sidecar HTTP endpoint

/notify is a mutating route on the loopback control API. Current main requires the X-API-Secret header.

The secret is not currently strictly process-scoped. __init__.py loads or writes DISCORD_VOICE_LIVE_SECRET_FILE (default ~/.hermes/voice-live-control-secret), so the same value can survive gateway and process restarts. Existing file ownership, type, symlink status, and mode are not revalidated before reading; see Issue #17. Do not assume that restarting the gateway rotates or invalidates the credential on the current implementation.

Do not use the old unauthenticated curl example: it returns 401 Unauthorized. The internal notification.sidecar_notify() helper also omits the required header, so its fallback path remains blocked by Issue #14. The accepted fix must resolve Issue #17's selected ephemeral process secret through a narrow in-process handoff, rotate it on every start, avoid stale caching, and fail closed when current credential state is unavailable or unsafe.

Keep port 18943 on loopback. Do not place the credential in a URL, query string, JSON body, repository file, shell history, log, returned error, or persisted notification entry. External callers need a separately reviewed trusted-local credential handoff; route protection must not be weakened to make an example work.

AFK and background delivery

Long-running delegation, email-brief, and scheduler paths can call the same dispatcher. Their success semantics inherit the boundaries above:

  • voice can be queued without proof of playback;
  • DM and channel require a live Discord adapter and resolvable target;
  • webhook success means enqueue acceptance, not confirmed HTTP delivery;
  • scheduled entries currently have the lifecycle defects tracked in Issue #13;
  • email-brief backend and de-duplication behavior is tracked separately in Issue #12.

Webhook fanout

The notification dispatcher uses the agent.notify event class. Configure targets with:

DISCORD_VOICE_LIVE_WEBHOOK_AGENT_NOTIFY=https://discord.com/api/webhooks/...

Webhook messages can contain notification text and source metadata. Treat the destination as a data recipient, verify its retention policy, and do not send transcript, email, credential, or private task content to an untrusted URL.

Notification sound effect

The local_notify tool attempts to play the notification sound-effect slot after dispatch, regardless of whether the selected remote path proved end-user delivery. Disable sound effects with:

DISCORD_VOICE_LIVE_SFX_ENABLED=false

See sfx-library.md.

When not to use

  • Do not use scheduled notifications for urgent or one-shot reminders until Issue #13 is resolved and exact-head tests prove retry-safe delivery.
  • Do not treat voice queueing or webhook enqueue counts as delivery receipts.
  • Do not expose /notify without its control secret or publish the sidecar beyond loopback.
  • Do not assume a restart rotates the current secret until Issue #17's selected ephemeral lifecycle is implemented and exact-head security tests pass.
  • For task results already returned to Gemini, prefer the tool result unless an additional trusted delivery channel is intentionally required.