1. Summary
Users want to be notified when a new preview is published to Tuist, with per‑branch filtering (e.g. “only main/develop”), delivered through the channel that fits their workflow — Slack, email, or a push notification into the Tuist mobile app. Today we only deliver Slack alerts for a narrow set of analytics‑driven rules, and we have no push stack at all.
This RFC proposes:
- A notification layer with pluggable channels (Slack, email, push, inbox) that the rest of the platform can call uniformly.
- Push notifications for the iOS and Android apps, with a deep‑link contract that opens the exact preview.
- A per‑user notification inbox with read/unread state, so users never lose notifications even if they miss them on a live channel.
- A small, event‑driven extension to the automation engine proposed in Automations Evolution, so “on
preview_created” is modelled the same way as existing scheduled automations and a user’s subscription (“only onmain/develop”) is just an automation with a filter.
The work is structured in phases so that Slack + email + inbox ship before we commit to the full push stack.
2. Motivation
Context from the triggering conversation:
User: can we get notifications on new builds that land into tuist and have settings for them to, for example, notify only on main/develop or all branches?
Pedro: Via email or push notification?
User: push notifications
This request is representative. A preview landing is the kind of moment where a team wants to tell somebody — “designers, try the new build”, “QA, here’s the staging build”, “@here the release candidate is ready”. Today Tuist can emit that kind of signal only through narrow analytics‑driven Slack alerts.
At the same time, the Automations Evolution proposal is re‑architecting alerting around a single condition → action engine. It assumes a scheduled tick model for analytical queries (flaky rate, duration regressions, bundle size). “A new preview has just been published” is a natural event, not something you’d poll for. Preview notifications are therefore also the forcing function for introducing event triggers into that engine.
We also repeatedly re‑implement small notification paths (Slack alerts, flaky test digests, hourly/daily reports, transactional account emails) without a shared abstraction. A proper notification layer pays back across all of those.
3. Goals & non‑goals
Goals
- Let a user say: “Notify me in Slack / by email / on my phone when a preview is published on branches X and Y (or all branches) for project Z.” Preferences are scoped per (user, project): I might be the only person subscribed to previews on one project while opting out on another, and each person on a project picks their own channels.
- Add push notifications to the iOS and Android apps, with a deep‑link contract that opens the exact preview.
- Give every user a notification inbox — a per‑user list of notifications they’ve received, with unread counts and mark‑as‑read, surfaced in both the dashboard and the mobile apps.
- Provide a single internal API for producing notifications, so callers describe what happened and the platform figures out the channels.
- Integrate cleanly with the automations engine — subscriptions should be expressible as automations, not a bespoke subsystem.
- Work in self‑hosted deployments, with explicit degradation when push/email infra is not configured.
Non‑goals
- Replacing the existing analytics alerting path. Migration is covered by the Automations Evolution proposal.
- Building a general‑purpose messaging/chat product inside Tuist.
- Rich digesting, quiet hours, snoozing, per‑device muting (future work; see §9).
- Shipping web push (browser notifications from
tuist.dev) — noted as a future extension.
4. Current state
- Slack: mature. Per‑account workspace install, per‑project channel binding. Used for a narrow set of analytics alerts and scheduled reports.
- Email: transactional only — confirmations, password resets, invitations. No generic “notification email” pipeline.
- Push: nothing. No APNs/FCM wiring, no device registration, no client permission prompts.
- Deep linking: already in place on iOS via universal links for
tuist.dev; the app parses preview URLs into a navigation destination. Android handles universal links for OAuth only — preview path prefixes need adding. - Alert rules: analytics‑centric (metric + deviation + window + branch filter), evaluated on a schedule. No event triggers exist today.
- Preferences: no user‑level or project‑level notification preference model.
- Preview lifecycle: previews have a single creation path server‑side, but it does not publish an event today. That is the hook point we need.
5. Design
The design is in three layers. Each layer is independently useful, and lower layers can ship without the upper ones.
┌───────────────────────────────────────────────────────┐
│ L3. Automation engine integration │
│ event triggers + send_push / send_email / … │
├───────────────────────────────────────────────────────┤
│ L2. Notification core + inbox │
│ one notification → many channels │
│ persistent per‑user feed w/ read/unread state │
├───────────────────────────────────────────────────────┤
│ L1. Delivery primitives │
│ Slack (reuse) · Email (extend) · Push (new) │
└───────────────────────────────────────────────────────┘
5.1 The notification model
A notification is a channel‑agnostic description of something that happened: a title, a short body, a canonical HTTPS URL for the thing it’s about (e.g. the preview page), some structured metadata, and an actor. Callers produce a notification; the platform decides how to render and deliver it.
Three properties are load‑bearing:
- One notification, many channels. Channel adapters render the notification. Callers never hand‑roll Slack blocks, email HTML, or push payloads.
- The URL is first‑class. Every notification carries the canonical URL for the thing it represents. That URL is the Slack CTA, the email button, the push payload, and the inbox row destination. See §6.3 for why this is the whole answer to the “push needs deep linking” concern.
- The inbox is just another channel. Landing a notification in the user’s inbox is a delivery on a synthetic
inboxchannel. Actions targetingselfalways deliver toinbox, and may additionally deliver to Slack / email / push based on preferences. This makes “inbox‑only, don’t ping me” a natural configuration.
5.2 Delivery channels
- Slack. Reuse today’s integration. Add a per‑user DM target in addition to per‑project channels, gated by an explicit opt‑in link to a user’s Slack identity (privacy).
- Email. Promote the existing mailer to a first‑class delivery channel with a generic notification template. All notification emails carry an unsubscribe header/link scoped to the subscription kind, so turning off preview emails never affects password resets.
- Push. New infrastructure on both server and mobile apps. Covered in §6.
- Inbox. Persisted per user. Covered in §5.5.
5.3 Automation engine integration
The Automations Evolution RFC models automations as {condition, actions, recovery} evaluated on a scheduled tick. Preview notifications don’t fit directly:
- The trigger is an event, not a condition re‑evaluated on a cadence.
- There is no “recovery” — a preview being published is not a state you return from.
- Actions want to target a user, not “a channel”.
We propose a small extension: introduce an event kind of automation alongside the existing scheduled kind. Same configuration shape, same action machinery, different dispatch. Event triggers in scope for this RFC: preview_created (the one driving this work) and build_completed as an alias. Future triggers — bundle_uploaded, test_run_failed, registry_release_published — fall out naturally and are listed only to validate the extension isn’t overfit to previews.
New actions: send_push, send_email, and send_slack with a user target (in addition to the existing channel target). Each can target self, a specific user, or a team. Actions are intentionally generic: they read the notification’s URL and metadata and ship it — they don’t know about previews.
Example user‑facing configuration, mirroring the Automations Evolution schema:
{
"name": "Notify me on main/develop previews",
"kind": "event",
"trigger": { "type": "preview_created", "filter": { "git_branch_in": ["main", "develop"] } },
"actions": [
{ "type": "send_push", "to": "self" },
{ "type": "send_slack", "to": "self" }
]
}
Most users won’t write JSON. The dashboard and apps expose a one‑click “Notify me about previews” toggle that writes an automation with "to": "self" under the hood — the same sugar layer the Automations Evolution RFC already anticipates.
5.4 Ownership: personal subscriptions vs. project automations
Notifications have two distinct kinds of producers, and conflating them is a modelling mistake. This split mirrors the dominant pattern across GitHub, GitLab, Linear, Vercel, and PagerDuty (see §11 Prior art):
- Personal subscriptions are owned by a user and scoped to a project. Each user picks independently: which kinds of events, which branches. Two users on the same project will frequently have different preferences, and one of them being subscribed says nothing about the other. These are the “Notify me about previews” toggles.
- Project automations are owned by the project, edited by maintainers, and visible to the whole team. They exist to express team‑wide intent like “post every preview on
mainto the#ios-buildsSlack channel” or “email design@ on every preview taggedrelease-candidate”.
Both use the same underlying engine and action set. They differ in who owns the row, who can edit it, and where it shows up in the UI:
| Personal subscription | Project automation | |
|---|---|---|
| Owner | (user, project) |
project |
| Visibility | Only the owner | All project members |
| Edited in | Profile → Notifications | Project → Settings → Automations |
| Typical actions | Deliver to the owner (push / email / DM / inbox) | Deliver to a channel, address, or team |
A maintainer looking at the project’s automations list sees only team‑owned automations — not other users’ personal subscriptions. Those live in each user’s profile.
Notification level per project. Borrowing from GitLab’s notification levels: for each project a user belongs to, the subscription collapses to one of a small enum — Off, All events, Default branch only, Custom (pick kinds and filters) — with a global default in account settings that a per‑project setting can override. This gives the global/per‑project composition users expect without a matrix UI.
Channels live with the user, events live with the project. Channel selection (push, email, Slack DM, inbox) is a user‑global preference in account settings. Per‑project configuration changes which events fire, not how they’re delivered. This matches GitHub, Linear, and Vercel, and keeps the per‑project UI small. Project automations remain free to route to any channel — that is, after all, their job.
Auto‑subscribe on participation. When a user causes something that will produce notifications (uploads a preview, is @‑mentioned, authors the PR a preview belongs to), they are auto‑subscribed to the resulting entity at the Default branch only level unless they have explicitly opted out. This is the cross‑platform default (GitHub, Linear, Sentry) and dramatically reduces the “I never set anything up and got nothing” failure mode. Auto‑subscription is always explicit in the UI so users can unsubscribe without digging in settings.
5.5 Notification inbox
Every notification targeted at a specific user lands in that user’s inbox: a persistent, per‑user feed of notifications, independent of which other channels the notification also fanned out to. The inbox is what guarantees that a user who misses a push, ignores an email, and isn’t in Slack at that hour can still catch up on what happened.
Behavior:
- Unread by default. Opening the deep link (from a push, email, or the inbox row itself) marks the notification read. The client tells the server on open so state stays in sync.
- Seen vs. read. We distinguish seen (rendered in the list — clears the unread badge) from read (the user engaged with it). This is a common UX pattern and is friendlier than requiring explicit clicks.
- Mark all as read, optionally scoped to a project or kind.
- Archive hides the notification from the default list while keeping the row for audit.
- Real‑time updates. Dashboard and apps receive live updates for new notifications and read‑state changes so unread badges don’t rely on polling.
The inbox is authoritative for “did this user receive this notification?”. External channel delivery (Slack accepted, APNs accepted, etc.) is tracked separately, so a push can fail without losing the inbox entry.
Email is a fallback, not a duplicate. Following Linear, Notion, and Vercel, we suppress email delivery if the inbox item has been seen or read within a short grace window (e.g. 5 minutes). Push and Slack DMs are not suppressed — they’re intentionally ephemeral and users opt into them. This single rule removes the “I read the notification on web and got an email anyway” annoyance that plagues many platforms.
6. Push notifications
Push is the largest new capability and deserves its own section.
6.1 What’s needed
- APNs for iOS and FCM for Android as the providers. No third‑party push SaaS in v1: push must work in self‑hosted deployments, and a SaaS would split behavior between cloud and on‑prem.
- Device registration: an endpoint the apps call to register and refresh their push tokens. Server soft‑disables tokens the provider reports as invalid.
- Permission prompts on iOS and Android 13+ at first launch after upgrade.
- Android manifest update to register preview URL paths as app links (iOS already does this via universal links).
- Notification channels on Android so users can mute categories (e.g. previews vs. alerts) in OS settings.
6.2 Payload shape
Two pieces of data per push:
- Display content: title and body for the OS banner.
- Navigation data: the canonical HTTPS URL of the thing the notification is about, plus a small set of structured fields (
kind,preview_id,project_handle,git_branch,git_commit_sha) for fast‑path rendering without re‑parsing the URL.
6.3 Reconciling push deep‑linking with a generic action
A generic send_push action needs to land the user on the right screen in the app without knowing anything about the notification kind. The design resolves this as follows:
- Every notification carries a canonical HTTPS URL for the thing it represents (e.g.
https://tuist.dev/{org}/{project}/previews/{id}). Producing that URL is the job of whoever creates the notification — in this case, the preview lifecycle code. send_pushis generic. It ships the URL and metadata. It does not know about previews.- The apps already know how to open these URLs. iOS routes them through universal links today; Android needs a small, one‑time manifest change to handle the preview path prefix.
- Future notification kinds (
test_run_failed,bundle_uploaded, …) get deep linking for free because their URLs are likewise already handled by the apps.
This keeps the automation engine notification‑kind‑agnostic and keeps deep‑link knowledge in the URL, which is the single place every surface — Slack CTA, email button, push tap, inbox row — already respects.
7. User experience
Dashboard
- Header bell icon with unread count, opening an inbox panel.
- Full inbox page with filters (unread / all / archived, by project or kind) and mark‑all‑as‑read.
- Project → Settings → Automations (team‑visible): project automations that fire for everyone — e.g. post previews to a Slack channel.
- Profile → Notifications (private to each user), in two parts:
- Channels (global): per‑channel master switches — push, email, Slack DM, inbox — plus an “Inbox only” shortcut.
- Projects: one row per project the user belongs to, with a notification level (
Off,All events,Default branch only,Custom). A default applies to every new project unless overridden. The same row is reachable from each project page via a “Notify me about this project” shortcut.
Mobile apps
- Permission prompt at first launch.
- “Push notifications for previews” toggle with branch filter; writes an automation with
"to": "self". - Inbox tab mirroring the dashboard: list with unread indicator, pull‑to‑refresh, swipe‑to‑archive, tap‑to‑open (which also marks read and deep‑links).
- Taps on a push go through universal links to the native preview screen.
Slack & email
- Slack: keep today’s project‑channel alerts; add opt‑in DMs.
- Email: generic notification template; per‑kind unsubscribe.
8. Rollout plan
Phase 0 — plumbing. Preview creation broadcasts an event. Persist notifications and inbox rows, no channels yet.
Phase 1 — Slack, email, inbox. Ship preview notifications through Slack and email, with a working inbox on the dashboard. Minimal subscription model; automation engine integration not required yet.
Phase 2 — Push. APNs + FCM, device registration, permission prompts, Android manifest update, mobile inbox tab, auto‑mark‑read on deep‑link open. iOS first, Android shortly after.
Phase 3 — Automation engine integration. Introduce the event automation kind, migrate the thin subscription model to be a materialization of automations. Sugar UI is unchanged.
Each phase is independently shippable. We only commit to push once Slack + email + inbox have told us whether users want push or whether Slack DMs and the inbox are enough.
9. Open questions
- Web push. Appealing for users who don’t install the app; out of scope for v1, but compatible with the URL‑based contract.
- Digesting. If a team pushes 40 previews in an hour, we should digest. Simplest version: a short window per
{automation, recipient}collapsing the Nth into “+ N more”. Defer, but reserve the schema room. - Self‑hosted push. APNs requires an Apple Developer team. For self‑hosted customers we likely route push through a Tuist‑operated relay; alternatives (customer‑configured APNs key with app re‑signing) are impractical. Needs Security + SRE sign‑off.
- Slack DM auto‑linking. Auto‑DM via the Slack user id from the workspace install vs. explicit per‑user link flow. Leaning explicit for privacy.
- Event delivery guarantees. At‑least‑once is acceptable with server‑side dedup; do we need client‑side dedup too?
- Scheduled vs event automation alignment. The Automations Evolution RFC leans schedule‑first. We should align on the
eventkind with the RFC author; the alternative is a separate subscriptions model, at the cost of duplicated UI/logic. - Seen vs. read. Is the extra state worth it, or do we collapse to a single
readstate?
10. Alternatives considered
- Stick with Slack only. Cheap, matches where most users live. Rejected: the request explicitly asked for push, and email is a small increment on top of the existing mailer.
- Third‑party notification provider (Knock, Courier, OneSignal). Shortest path to push. Rejected for v1 because push must work in self‑hosted deployments; introducing a SaaS would split behavior between cloud and on‑prem. Reasonable to revisit later.
- Per‑feature webhooks (GitHub‑style). Useful but orthogonal; we will ship webhooks in the automation engine regardless, and webhooks don’t replace a first‑party mobile push experience.
- Fold subscriptions into the existing analytics alert rules. Rejected: those rules are being subsumed by the automation engine; retrofitting event triggers onto them is strictly worse than the
eventkind on the new engine. - Model every notification as a scheduled condition (“is there a preview newer than the last I saw”). Rejected: wastes resources, adds latency proportional to the poll interval, doesn’t match the semantics.
11. Prior art
Surveyed platforms: GitHub, GitLab, Linear, Jira, Vercel, Netlify, Slack, Notion, Sentry, PagerDuty, Figma. A few patterns show up consistently and shaped the design above:
- Project routing and personal preferences are separate systems that both emit into a common notification stream. PagerDuty is the cleanest example — escalation policies decide who gets paged, personal notification rules decide how each person is reached. GitHub (webhooks/apps vs. personal notification settings), Jira (notification schemes vs. personal prefs), and Vercel (team integrations vs. “My Notifications”) all follow the same split. Linear blurs it least but still keeps the Slack integration orthogonal to personal channels. → Informs §5.4.
- Global channel selection, per‑project event selection is the dominant shape (GitHub, Linear, Vercel). Only Jira and Netlify do it the other way, and both are widely considered heavy. → Informs §5.4 and §7.
- A small notification‑level enum, composed across scopes, is GitLab’s contribution: the same enum (Global / Watch / Participate / On mention / Custom / Disabled) applies at global, group, and project level, with project overriding group overriding global. Much cheaper than a per‑event matrix. → Informs §5.4.
- Inbox‑first with email as a fallback. Linear, Notion, and Vercel all suppress email when the user has already engaged with the in‑app notification within a short window. This collapses the “I read it on the web and got an email anyway” double‑notify problem. → Informs §5.5.
- Auto‑subscribe by participation. GitHub (“Participating”), Linear (create/assign/mention), Sentry (deploy notifies committers) all default to subscribing users to entities they touched, then let users unsubscribe. The alternative — require explicit opt‑in everywhere — consistently under‑delivers. → Informs §5.4.
- Mark‑read on open, not on click, is the modern default. Opening the inbox popover counts as “seen”; deep links from any channel carry a notification id so clicking through marks the notification consumed server‑side. → Informs §5.5.
What we deliberately don’t copy:
- Jira‑style notification schemes (project role × event → recipient matrix). Too heavy for our context, and the automations engine already expresses the same thing more flexibly.
- Figma‑style “in‑app cannot be disabled” floor. Reasonable for collaborative design; feels paternalistic for our use case. The inbox can be muted per project via the
Offlevel. - PagerDuty‑style escalation chains with retry delays across channels. Overkill for preview notifications; right primitive when we eventually do incident/on‑call notifications.
12. Appendix — end‑to‑end example
- CI uploads a build; the preview is created and an event is published.
- The engine finds a matching event automation (a user’s subscription to previews on
main/develop) and runs its actions. send_pushfans out to the user’s registered devices; the notification is also recorded in the user’s inbox as unread.- The phone shows “New preview — main · a1b2c3d”. Tapping opens the preview URL via universal links into the native preview screen; the inbox item is marked read in the same motion.
Out of this RFC, worth tracking separately
- Per‑device push preferences (mute this device while keeping others).
- Localization of push copy.
- Marketing vs. transactional consent — legal review.
- Delivery observability: metrics per channel, provider error budgets.