Notifications overview
Knock is the workflow engine for every user-facing notification in the platform. The shared @repo/notifications package owns the bilingual translations (es + en) and the branded HTML email layout — both apps/svelte-web (web triggers) and apps/worker (background-job triggers) call getTranslatedPayload(workflowId, locale, params) to get a ready-to-render { inAppBody, emailSubject, emailBody } payload. Knock dashboard templates render {{ inAppBody }} / {{ emailSubject }} / {{ emailBody }} as-is — no dashboard-side template logic.
Trigger surfaces and channel fan-out
Section titled “Trigger surfaces and channel fan-out”flowchart LR
Web["apps/svelte-web<br/>form action / tRPC"]
Worker["apps/worker<br/>BullMQ job"]
StreamWebhook["GetStream webhook<br/>(message.new)"]
API["apps/api<br/>tRPC mutation"]
Wrapper["triggerWorkflow()<br/>@repo/server-services"]
WorkerWrapper["triggerWorkflow()<br/>apps/worker/src/knock.ts"]
Knock["Knock<br/>(workflow engine)"]
Feed["In-app feed<br/>(web + mobile)"]
Email["Email<br/>(via Resend)"]
Push["Push<br/>(FCM + APNs per app)"]
Web --> Wrapper
API --> Wrapper
StreamWebhook --> Wrapper
Worker --> WorkerWrapper
Wrapper --> Knock
WorkerWrapper --> Knock
Knock --> Feed
Knock --> Email
Knock --> Push
All inter-component communication goes through the wrapped triggerWorkflow helper so observability + Sentry context lives in one place. Email channel is Resend, configured at the Knock dashboard level. Push covers FCM (Android, shared) + APNs per app — the agent app on KNOCK_APNS_CHANNEL_ID, the customer app on KNOCK_APNS_USER_CHANNEL_ID, routed by registerDevice’s app field. The customer APNs channel must be provisioned in the dashboard before iOS customer push delivers.
The 17 active workflows
Section titled “The 17 active workflows”| Workflow key | Domain | Trigger | Recipients | Channels |
|---|---|---|---|---|
new-inquiry | Chat | First property chat message creates Inquiry | Assigned agent | in-app + email |
new-chat-message | Chat | GetStream message.new webhook | Channel members minus sender | email (digest) |
team-invitation | Teams | Team invite created | Invited user | in-app + email |
tour-requested | Tours | Tour created | Agent | in-app + email |
tour-rescheduled | Tours | Tour PATCH → RESCHEDULED | Recipient (customer or agent) | in-app + email |
tour-denied | Tours | Tour PATCH → DENIED | Tour requester | in-app + email |
tour-confirmed | Tours | Tour PATCH → CONFIRMED | Customer | in-app + email |
tour-cancelled | Tours | Tour cancelled | Other tour participant | in-app + push + email |
tour-completed | Tours | Tour marked complete | Customer | in-app + push + email |
lead-assigned | Leads | leads.assign succeeds | Assignee | in-app + email |
lead-sla-breach | Leads | Worker sla-monitor tier 1 breach | Assignee | in-app + email |
lead-sla-escalation | Leads | Worker sla-monitor tier 2 (ADMINs) + tier 3 (LEAD) | Admins / team lead | in-app + email |
lead-ingestion-failed | Leads | Worker DLQ write after 3 failed retries | All admins | in-app + email |
saved-search-match | Alerts | Worker saved-search-scan cron (*/30 * * * *) | Saved-search owner | in-app + email |
price-drop | Alerts | Worker pricing listener (ACTIVE → lower price) | Property favoriters | in-app + email |
listing-status-change | Alerts | Worker status listener | Property favoriters | in-app + email |
staging-variants-ready | Listings | Worker sweep: every AI room-staging job on a listing has settled | Staging requester | in-app only |
The 9 rental-* workflows that previously sat alongside these were removed in May 2026 alongside the Rental Companion deferral — they will be re-authored fresh when the module returns.
The Channels column is the baseline. A June 2026 delivery pass (1) added push to every customer/agent-facing workflow (only lead-ingestion-failed and team-invitation stay in-app + email, and staging-variants-ready — added later — is in-app only), (2) made the notification-style workflows suppress the fallback email once the recipient reads the in-app/push notification (delay 10m → branch-on-read → email) while transactional emails always-send, and (3) gave saved-search-match + price-drop a 1h batch step (anti-spam). The workflow definitions are codified under knock/ and managed with the Knock CLI — see configuration and packages/notifications/FEATURE.md for the per-workflow detail.
Source-of-truth files
Section titled “Source-of-truth files”| Concern | File |
|---|---|
Translations + email layout + escapeHtml | packages/notifications/src/ |
| Server wrapper (web + apps/api) | packages/server-services/src/integrations/knock.ts |
| Server wrapper (worker) | apps/worker/src/knock.ts |
| Web client SDK | apps/svelte-web/src/lib/knock-client.svelte.ts |
| Web identify call | apps/svelte-web/src/routes/+layout.server.ts |
tRPC notifications.registerDevice (push) | packages/api/src/routers/notifications.ts (native client side lands in a later phase) |
| Worker trigger surfaces | apps/worker/src/leads.ts, apps/worker/src/alerts.ts |
| GetStream webhook | apps/svelte-web/src/routes/api/stream-webhook/+server.ts |
Translation pipeline
Section titled “Translation pipeline”import { getTranslatedPayload } from '@repo/notifications';import { triggerWorkflow } from '@repo/server-services';
const payload = getTranslatedPayload('new-inquiry', recipientLocale, { customerName: 'Ana López', propertyAddress: '...', // ...});
await triggerWorkflow('new-inquiry', [agentUserId], payload);- Per-recipient locale is resolved from the local
User.localecolumn (already populated at signup). The Knock-sidegetUserLocaleis a fallback for users created before locale tracking landed. - Worker uses
Promise.allSettledwhen fanning out to multiple admins so a single locale failure doesn’t block the batch. - Email subject + body are server-side HTML composed via
email-layout.ts(wrapEmailLayout,ctaButton,calloutBox,quoteBlock,propertyCard,detailTable/detailRow). All user-controlled strings go throughescapeHtmlfrom@repo/notificationsbefore interpolation — email HTML escaping is OFF in the Knock dashboard because we escape server-side.
Web feed + mobile feed
Section titled “Web feed + mobile feed”| Surface | What it does |
|---|---|
| Web | knock-client.svelte.ts — reactive runes for knockUnreadCount / knockNotifications / knockInitialized. Real-time WebSocket via feedInstance.listenForUpdates(). Sonner toast for new items, skipping new-chat-message (Stream handles its own chat UI). |
| Native mobile | The in-app feed + unread badge land in a later native phase (Knock’s native SDKs) — see Conventions → Mobile (Native). The server-side trigger + channel fan-out are unchanged. |
Where this is going
Section titled “Where this is going”The active enhancement plan in .claude/plans/ covers:
- Security — signed user identification tokens (the highest-severity open gap).
- Observability — wrapper for every trigger with Sentry context + Knock delivery webhooks.
- Mobile parity — customer-app unread badge, foreground toasts, iOS-ready scaffolding, sign-out unregistration, locale resync.
- Per-workflow improvements — tour reminders (scheduled triggers +
cancellation_key), pre-breach SLA warning, digest mode for chat + saved-search-match + price-drop. - User preferences — Knock preference groups for chat / tours / leads / alerts / teams / system.
See Configuration for env vars and the Knock dashboard checklist.