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:
~/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.- 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 callsnotify-portfolioafter the report lands.
The provider tradeoff (what to research before building)
| Provider | Strengths | Weaknesses | Verdict |
|---|---|---|---|
| Twilio — https://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.to — https://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. |
| Pushover — https://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 API — https://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 webhooks — https://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 webhooks — https://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 Resend — https://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) directly — https://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 callback — https://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. |
Recommended channel matrix (the editorial part)
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 (primary → fallback). 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)
notify-portfolioCLI v1: SMS via Twilio + push via Pushover only (the two channels covering 90% of cases). YAML config; op:// for creds.--notifyflag ondare_sitemap_regen.py+dare_404_audit.py+dare_dashboard_refresh.sh— opt-in, off by default.- Self-test cron weekly per channel.
- Watch-item escalation to push when audit thresholds tripped.
- Telegram channel if visual previews (sitemap diff thumbnails, narrator screenshots) start mattering.
Attribution & references
- Twilio SMS API — https://www.twilio.com/docs/sms/api
- Twilio Programmable Voice — https://www.twilio.com/docs/voice
- sms.to API — https://sms.to/developer (already integrated in dare contact-form worker per CLAUDE.md)
- Pushover API — https://pushover.net/api (one-off $5 lifetime; designed for personal alerting)
- Telegram Bot API — https://core.telegram.org/bots/api (free; rich messaging)
- Resend API — https://resend.com/docs (already in portfolio — dare contact form uses RESEND_API_KEY)
- voip.ms API — https://wiki.voip.ms/article/REST_API (Dan has beta access; AI-voice callback is the unique surface)
- A2P 10DLC for US toll-free SMS — https://www.twilio.com/docs/messaging/compliance/a2p-10dlc (registration overhead pre-launch)
Internal references
feedback_layered_guardrail_stack.md— every channel’s creds in Code Shared, narrowly-scopedfeedback_check_devreports_before_infra_gap_analysis.md— sweep before building (no existing notify-portfolio script today)feedback_just_in_time_permission_grants.md— defer minting Twilio API key / Pushover app until a real workflow needs itfeedback_save_vs_find_alignment.md— recipient indirection (alias → underlying address) is the save-vs-find principle applied to notification routingproject_audrey_entity_twilio_*— existing Twilio setup for audrey (already-paid integration; shared account possible)~/bin/cf-access— pattern for op:// credential wrapping; notify-portfolio follows the same shape
Push the report when there’s something to read. Be quiet otherwise. Wake the human only when the human’s judgement is actually required.