dare_messaging_service — architecture sketch · 2026-05-14

Parked workstream. Portfolio-portable notification micro-service: send SMS / push / email when a named report is ready to read, or when a watch-item threshold trips. Same shape as the existing publishing + sitemap pipelines — runs locally or in CI, opt-in from any script that emits a report, configured per-channel via 1Password references.

Genesis: today Dan asked, “could certain reports SMS me when ready to be read, or alerts, using Twilio, or whomever you like — its a service that runs and can be consumed.” This is the design + provider tradeoff catalogue.

What it does

A single command-line entry point any other script can call after emitting a report:

~/bin/notify-portfolio --channel sms --to dan \
    --subject "sitemap regen done" \
    --body "671 URLs, 9 non-200 image probes" \
    --link "https://devreports.dare.co.uk/dare_sitemap_regen_2026-05-14"

Decides which provider to route through based on channel + recipient config, sends the message, returns 0 on success.

The wider system is two pieces:

  1. ~/bin/notify-portfolio — the synchronous send command (the “publishing service” Dan referred to). Reads provider creds from 1Password by op:// reference; routes per-channel; logs to ~/Library/Logs/dare-pipeline/notify.log.
  2. Opt-in hook in report-emitting scripts — each script (dare_sitemap_regen.py, dare_404_audit.py, dare_dev_reports_publish.py, etc.) gets an optional --notify <channel:recipient> flag that calls notify-portfolio after the report lands.

The provider tradeoff (what to research before building)

Provider Strengths Weaknesses Verdict
Twiliohttps://www.twilio.com/docs/sms Most mature SMS API; per-message ~$0.0075 US, ~£0.04 UK; programmable voice + WhatsApp + email under one account; in-portfolio for audrey contact form already; toll-free verification done UK-bound SMS pricing ~5-10× US; opaque billing alerts; A2P 10DLC registration overhead for new senders First choice — already in portfolio (feedback_* memories reference Twilio for audrey), shared billing, mature retry semantics. Drives --channel sms.
sms.tohttps://sms.to Cheaper than Twilio for UK/EU, simpler API, used by dare contact form Worker per CLAUDE.md Younger company, fewer integrations beyond SMS, no native voice / WhatsApp / push Fallback for sms — keep as second-route when Twilio cost matters or sender ID needs differ.
Pushoverhttps://pushover.net/api Designed for personal alerting; one-off $5 per platform (iOS/Android/desktop) covers lifetime use, no per-message cost; recipient is just a “user key” — no phone number; iOS+Android push + email-to-push Personal-scale only (rate limit 10k/month per app, generous); requires recipient to install app Strongest for “Dan, look at this report” channel — zero per-message friction, push notifications go directly to phone/watch, link tap opens devreports. Drives --channel push.
Telegram Bot APIhttps://core.telegram.org/bots/api Free; rich-text + inline buttons + image previews; one bot serves any chat; no per-message cost Recipient needs Telegram; not great for transactional/critical (best-effort delivery) Strongest for visual reports — bot can preview thumbnail + tap-through to devreports. Drives --channel telegram.
Slack webhookshttps://api.slack.com/messaging/webhooks Free for team workspaces; rich attachments; structured “blocks” formatting Requires Slack workspace; team channel implies others see the notification Skip for personal alerting; revisit if/when dare-pipeline becomes a multi-person operation.
Discord webhookshttps://discord.com/developers/docs/resources/webhook Free; rich embeds; webhook URL only; no auth dance Same “team channel” caveat as Slack Skip for personal alerting; same revisit trigger.
Email via Resendhttps://resend.com/docs Already in portfolio (dare contact form); transactional; templated; 100/day free, cheap thereafter Email is high-latency vs SMS/push; recipient inbox-noise problem Useful for digest/daily-summary — not for “report ready in 90s” alerts. Drives --channel email.
Apple Push (APNs) directlyhttps://developer.apple.com/documentation/usernotifications Lowest-latency on Apple devices; native Requires Apple Developer account ($99/yr), iOS app to build + ship to receive Skip until we have native iOS surface. Pushover is the pragmatic shortcut for the same UX.
voip.ms AI-voice callbackhttps://voip.ms/api/v1 (Dan has beta access, per CLAUDE.md follow-up) Voice channel for high-priority alerts; AI agent can read out report summary Voice is intrusive; only useful for genuinely-urgent alerts (e.g. dashboard cron failure, security event) Reserve for --channel voice urgency tier — not for routine “report ready” pings.

Rough guidance for which channel maps to which alert class:

Alert class Channel Provider Rationale
Routine “report ready” (sitemap, audit, narrator) push Pushover Tap-through to devreports; zero per-message cost; lockscreen visibility
Watch-item threshold trip (e.g. 404-audit >5 broken, sitemap >20 dead URLs) push + telegram (dual) Pushover + Telegram Push for immediate; Telegram for image-rich follow-up (chart, screenshot)
Cron failure (pipeline didn’t run) sms Twilio Critical; needs out-of-band reach; ignore-able-only-with-effort
Security event (Access policy change, token rotation, CDN, security layer, and DNS provider sitting in front of dare.co.uk.">CF rule edit) sms + voice Twilio + voip.ms Maximum salience
Daily/weekly digest email Resend Long-form; archive-able; doesn’t interrupt

Architecture sketch

                    ┌─────────────────────────────────────┐
                    │     report-emitting scripts          │
                    │  dare_sitemap_regen, dare_404_audit, │
                    │  dare_dashboard_narrator, etc.       │
                    │                                       │
                    │   each carries --notify <ch>:<who>    │
                    └──────────────┬───────────────────────┘
                                   │
                                   ▼
              ┌────────────────────────────────────────────┐
              │     ~/bin/notify-portfolio                 │
              │                                             │
              │  args: --channel sms|push|telegram|email   │
              │        --to <recipient-alias>              │
              │        --subject + --body + --link         │
              │                                             │
              │  reads:  ~/.config/notify/recipients.yaml  │
              │          ~/.config/notify/channels.yaml    │
              │  creds:  op:// from Code Shared vault       │
              │                                             │
              │  per-channel dispatcher:                    │
              │    sms  → Twilio    (op://Code Shared/...) │
              │    push → Pushover  (op://Code Shared/...) │
              │    tg   → Telegram  (op://Code Shared/...) │
              │    mail → Resend    (op://Code Shared/...) │
              │    voice → voip.ms  (op://Code Shared/...) │
              │                                             │
              │  logs:   ~/Library/Logs/dare-pipeline/      │
              │          notify.log (rotated weekly)        │
              │                                             │
              │  exit 0 on success; non-zero on send fail   │
              └────────────────────────────────────────────┘

Per-channel config shape (1Password references)

Following feedback_layered_guardrail_stack.md — every credential narrowly scoped.

# ~/.config/notify/channels.yaml
channels:
  sms:
    primary:
      provider: twilio
      account_sid:     op://Code Shared/Twilio dare-pipeline/account_sid
      auth_token:      op://Code Shared/Twilio dare-pipeline/auth_token
      from_number:     op://Code Shared/Twilio dare-pipeline/from_number
    fallback:
      provider: sms.to
      api_key:         op://Code Shared/sms.to dare-pipeline/api_key
      sender_id:       op://Code Shared/sms.to dare-pipeline/sender_id
  push:
    provider: pushover
    app_token:         op://Code Shared/Pushover dare-pipeline/app_token
    # user_key lives in recipients.yaml per-recipient
  telegram:
    provider: telegram
    bot_token:         op://Code Shared/Telegram dare-pipeline-bot/bot_token
  email:
    provider: resend
    api_key:           op://Code Shared/Resend dare-pipeline/api_key
    from_address:      noreply@dare.co.uk
  voice:
    provider: voipms
    api_username:      op://Code Shared/voip.ms dare-pipeline/api_username
    api_password:      op://Code Shared/voip.ms dare-pipeline/api_password
# ~/.config/notify/recipients.yaml
recipients:
  dan:
    phone:        op://Code Shared/Recipient dan/phone
    pushover_key: op://Code Shared/Recipient dan/pushover_user_key
    telegram_id:  op://Code Shared/Recipient dan/telegram_chat_id
    email:        op://Code Shared/Recipient dan/email

Recipient indirection allows scripts to say --to dan without ever knowing the underlying phone/email/key. Adding a second human (a client check-in) is one file entry.

Pitfalls + traps

Category A — Delivery reliability

Trap Mitigation
Provider outage mid-cron (Twilio API 500s for 5 min) Per-channel fallback in channels.yaml (primaryfallback). Log the fallback so we know it kicked.
Silent rate-limit Pushover: 10k/mo per app — easily under for personal scale, but worth logging cumulative usage. Twilio: per-second throttle — sequential retry with backoff.
Recipient phone offline / DND Push (Pushover) has its own retry; SMS is fire-and-forget. Don’t synthesise retries on SMS — let the provider’s own queue handle.
Webhook secret in transit When this thing eventually exposes a webhook for “report-ready” callbacks from CI, the secret must be in an Authorization header, not the URL.

Category B — Content shape

Trap Mitigation
Long body truncated mid-word SMS hard limit ~160 chars; Pushover 1024 chars; Telegram 4096. Truncate at last-space-before-limit, append + link.
Special characters in body breaking JSON Use json.dumps for the request body; never string-concat. (Echo of the XML pitfall in the sitemap sketch — same class of bug.)
Link in SMS expanding via carrier link-shorteners Use a short canonical link (https://devreports.dare.co.uk/<slug>); don’t go through bit.ly etc.
Emoji in body Twilio + Pushover support; SMS gateways sometimes mangle. Keep emoji opt-in via a per-channel unicode_safe: true|false flag.

Category C — Recipient privacy

Trap Mitigation
Phone number leaked in log file notify.log redacts the last 4 digits: +44******1234.
Pushover user key in env var visible to subprocess Inject via stdin (Twilio-style auth parameter) where possible, not env.
Recipient recipients.yaml accidentally committed to git File lives at ~/.config/notify/, not in any repo. recipients.yaml excluded by .gitignore if the path ever moves.

Category D — Cost surprise

Trap Mitigation
Cron storm during outage (every-minute pipeline fails for 6 hours = 360 SMSes) Default --rate-limit per channel: max 5 messages per hour to the same recipient with the same subject hash. Cron-failure alerts deduplicate.
Voice call woke neighbour at 3am --channel voice requires explicit --urgency critical flag — never default.
Stale fallback provider racks up bills if primary stays down Log fallback used to dashboard.dare.co.uk; weekly summary catches the pattern.

Category E — Provider drift

Trap Mitigation
Twilio API version pin Pin in channels.yaml: version: "2010-04-01". Same shape as the Shopify API version pin from the Audrey path-engine sketch.
Provider API surface change without notice Per-provider integration test runs weekly: notify-portfolio --self-test --channel <ch> sends a no-op probe; logs success.
Provider account locked / payment failed Probe failures escalate via the other channel (SMS provider locked? → push notification reaching out via Pushover). The dependency graph stays acyclic by always falling back to a different provider.

Category F — Composition with other tools

Trap Mitigation
Script that emits report but doesn’t tail-call notify Make --notify a standard arg across all dare-pipeline scripts. Pattern documented in feedback memory.
Notification fires before report is actually published Sequencing: report-write → publish-to-devreports → THEN notify. The link in the notification must resolve. notify-portfolio accepts --require-200 <url> flag that HEAD-checks before sending.
Notification on cron success is noise Default to “notify on watch-item or failure only” — quiet successes. Daily digest catches the quiet wins.

Wire-up

Surface How
Manual one-off ~/bin/notify-portfolio --channel push --to dan --body "x"
From a script subprocess.run(["notify-portfolio", "--channel", "push", "--to", recipient, ...])
Cron-quiet by default --on success skip; --on watch-item ping; --on failure sms
Self-test notify-portfolio --self-test --channel <ch> weekly via cron
Logs ~/Library/Logs/dare-pipeline/notify.log; weekly rotation; redacted recipients
Memory project_*_notify_service.md per portfolio site; feedback_notify_*.md for emerging conventions

Compounding across portfolio

Site Notification class today Future notification class
dare.co.uk None (devreports is a pull surface) “Sitemap regen done, 9 watch items” push, “Dashboard cron failed” SMS
dogwood.house None “Booking inquiry from form” SMS (already partial via existing contact route)
audreyinc.com Twilio SMS confirmation in contact form already Order alerts (when a high-value scarf sells), gift-guide-CTR thresholds
dansellars.com None Per-comment-or-share threshold
Client engagements Per-engagement The same micro-service, different recipients in YAML

Resume conditions

Build when one of these triggers: - A second report category genuinely needs out-of-band notification (today, “Dan reads devreports when he sits down” is enough for the sitemap + audit cadence; the messaging service buys nothing yet). - A cron failure case becomes load-bearing (e.g. the dashboard narrator misses a day and no one notices for hours — push-on-failure becomes worth it). - An audrey commerce alert needs SMS (high-value order, low-stock trigger) — turns push notifications into customer-facing value. - A dogwood booking flow needs the SMS confirmation tier — service quality improvement.

Until then: parked. Cross-references: feedback_park_with_resume_conditions.md.

What I’d build first (when triggered)

  1. notify-portfolio CLI v1: SMS via Twilio + push via Pushover only (the two channels covering 90% of cases). YAML config; op:// for creds.
  2. --notify flag on dare_sitemap_regen.py + dare_404_audit.py + dare_dashboard_refresh.sh — opt-in, off by default.
  3. Self-test cron weekly per channel.
  4. Watch-item escalation to push when audit thresholds tripped.
  5. Telegram channel if visual previews (sitemap diff thumbnails, narrator screenshots) start mattering.

Attribution & references

Internal references


Push the report when there’s something to read. Be quiet otherwise. Wake the human only when the human’s judgement is actually required.

Source: dare_messaging_service_sketch_2026-05-14.md · Rendered 2026-05-14 09:33