Build Flows

Workflow automation · October 10, 2026 · 11 min read

Construction Alerts and Notifications Over Email, Web Push and SMS

Connect separates deciding that something happened from deciding who hears about it: rules and slip alerts fire into one notification gate that applies preferences, quiet hours and caps across in-app, web push, email and SMS.

By Charley Forey, founder of Build Flows

An alerting system for a construction platform has two jobs that pull in opposite directions. It has to tell a project manager the moment the critical path slips, and it has to not text that same person at 2 a.m. because an RFI count ticked over a threshold. Most alerting we have seen in construction software gets one of those right. Either it is quiet and people miss things, or it is noisy and people mute it, which ends up the same.

In Connect we built alerts and notifications as two separate modules with one narrow seam between them. Alerts decides that something happened. Notifications decides who hears about it, on which channel, and when. This article walks through both: the rule grammar, the metric evaluator, schedule slip alerts, the AI drafter that writes rules from plain English, the audit trail, and delivery over in-app, web push, email and SMS under each person's preferences.

Three ways an alert starts

SourceWhat triggers itWho writes it
Schedule slip alertsA new capture of a schedule finishes projecting and is compared to the previous oneBuilt in, no configuration
Schedule intelligenceA detector flags a weather, RFI, change order or delivery risk at warning severity or aboveBuilt in
Alert rulesA metric crosses a condition on connector data, or a named domain event firesUsers, by form or by AI

Schedule slips are the most important and the least configurable. When a sync lands a new capture, the projector compares it to the previous capture. If the forecast finish moved out, it records a critical_path_slip alert. A week or more of slip is critical, anything less is a warning. If activities became newly critical, it records a newly_critical alert. A first sync has nothing to compare to, so it raises nothing.

Each slip is written to a ledger table with a unique constraint on (workspace, schedule, kind, snapshot). Re-running a projection, retrying a sync or replaying a capture cannot produce a second alert for the same slip. Only a newly inserted row fans out to the workspace feed, and the same event is then dispatched to any user-defined rules subscribed to it.

Schedule intelligence runs detectors across the schedule and connector data. The informational ones stay in the project's recommendations list. Anything at warning or above goes through the same slip ledger, so it reaches people the same way.

Rules are where users take control, and where most of the design work went.

One grammar, four consumers

The rule grammar lives in a single dependency-free catalog file: the allowed aggregate functions, condition operators, filter operators, severities, notification categories, event kinds and starter templates. Four different things read it:

  • the evaluator, which runs conditions,
  • the AI drafter, whose prompt is built from these lists,
  • the HTTP routes, which validate input at the boundary,
  • the client, which fetches the catalog to build the rule form.

A rule is either a metric rule or an event rule.

A metric rule points at one connector's record kind (open Procore RFIs, commitments, weather forecasts) and defines one or more conditions combined with AND or OR. Each condition is an aggregate (count, sum, avg, min, max) compared with an operator. The operators are the usual comparisons plus three special ones: anomaly, which compares the value to the rule's own history; due_within, which counts rows whose date falls in the next N days; and overdue, which counts rows whose date has passed. Optional filters narrow rows first, and an optional window limits the metric to the last N days by a date field.

An event rule subscribes to a named domain event. The ones wired to live emitters today are critical path slips, newly critical activities, sync failures and successes, connection errors, a connector that needs reauthorization, recording summaries, job walk reports, Info Sheet updates, and new members. The catalog also reserves a few kinds, such as milestone slips and overdue schedules, that are designed but not yet emitted. Each event belongs to a notification category, which later decides which preferences apply.

Both kinds carry a severity (info, watch or critical), recipients, an evaluation interval, a minimum consecutive count, an optional escalation ladder and an optional webhook.

The catalog also exports one validation function that returns a list of human-readable problems. It is deterministic and total. The form, the API and the AI drafter all go through it, so an invalid rule never reaches the store, no matter who wrote it.

The metric evaluator

Connector data lands in one table of JSON payloads keyed by workspace, connection and record kind (the connector framework article covers how). A metric rule is therefore a query over JSON, and the field names come from users and from a model. That combination is how SQL injection happens, so the evaluator is strict about it.

Every field name is a bound parameter used as a JSON key. Every operator comes from a whitelist map. Nothing from the rule is ever spliced into SQL text. A condition compiles to something like:

SELECT COALESCE(SUM((payload->>$4)::numeric), 0) AS v
  FROM connector_records
 WHERE workspace_id = $1 AND connection_id = $2 AND record_kind = $3
   AND deleted_at IS NULL
   AND (payload->>$5) = $6          -- a filter
   AND (payload->>$7)::timestamptz >= now() - make_interval(days => $8)

Firing logic sits on top:

  1. Measure every condition and combine them.
  2. If the result is true, increment the rule's true streak. Otherwise reset it to zero.
  3. Fire only on the rising edge: the streak has reached the rule's minimum consecutive count and there is no open event for this rule already. A condition that stays true for a week fires once.
  4. If the rule was firing and is now false, resolve its open events and send a "recovered" notice.
  5. Record the value, the streak and the check time.

anomaly uses a z-score against a ring buffer of the rule's last 96 values. It needs at least eight samples before it can fire, and a flat history never counts as anomalous, so a new rule does not alert on noise. Three standard deviations is the threshold. This is deliberately simple, and the code says so.

A rule that throws, because its field disappeared or its connection was deleted, has its streak reset and gets retried next poll. One broken rule never blocks the rest of the batch.

A Preview button runs the same evaluator against live data without recording anything, which catches most "why does this never fire" problems before the rule is saved.

Escalations and webhooks

An escalation ladder is a list of steps, each with a delay in minutes and a set of people. A sweep on every worker poll looks at open events on rules with ladders, works out which step each has aged into, and notifies everyone on the steps it skipped past, at critical severity. Acknowledging or resolving an event stops the ladder. Each escalation has its own dedup key, so a sweep that runs twice does not page twice.

A rule with a webhook URL (it must be https) queues a delivery row when it fires. The worker posts the JSON with a 10-second timeout, retries after one minute and then five, and marks the row failed after the third attempt. A slow endpoint can never hang the poll.

AI that writes rules, and the trail it leaves

Most people do not think in aggregates and operators. They think "warn me when open RFIs go above 20." So Connect has two AI paths into the same grammar.

The Alert Drafter is a single structured completion on our shared pi-agent-core runtime, at minimal reasoning, with no tools. Its prompt is generated from the catalog's lists, so it cannot drift from what the evaluator supports. Its output goes through a coercion step that does not trust the model for anything that matters:

  • connection and record kind come from context, never from the model,
  • channels default to in-app only, recipients to empty, the interval to 15 minutes, the minimum streak to one, and no webhook or escalation,
  • unknown functions or operators fall back to safe defaults rather than passing through.

Then the validation function runs. A draft with gaps comes back as a list of problems, not a saved rule.

In chat, Connect AI reaches the drafter through the draft_alert_rule delegation tool. If the draft is valid, the tool shows it inline and pauses the conversation. Unlike the other agents that pause, the drafter has no store of its own, so the draft rides on the chat's pending-delegation pointer as a JSON payload. The user's next message is classified as accept or reject. Accept creates the rule from that payload, reject discards it, and anything ambiguous asks again. The delegation article covers the pointer itself.

The Alert Assistant is the longer conversation for harder rules. It runs on the OpenAI Agents SDK with tools to list the workspace's connections, the record kinds each has produced, and the field names sampled from real rows. That grounding is the point: it proposes rules using fields that actually exist. It can preview a rule against live data with the same evaluator before proposing it, and the user accepts or rejects it. It also accepts voice, transcribed through Azure Speech when that is configured. The user's text is screened by the shared safety seam before either agent sees it.

Whichever path creates a rule, the alert_rule_audit table records it. Create, update, delete and snooze each write a row with the rule, workspace, user, action and detail. Rules change who gets woken up, so who changed a rule and when has to be answerable.

Notifications: one gate for every source

Every alert source, and several things that are not alerts (a client acknowledging a project Update, a share nobody opened), deliver through one method on the notifications module. Alerts depends only on that one method, through a small interface, so the two modules stay one-directional and the evaluator can be tested with a stub.

For each firing, the gate:

  1. Resolves recipients. An empty list means every workspace member.
  2. Skips anyone who has muted that category until a future time.
  3. Skips anyone whose minimum severity for that category is above this firing.
  4. Inserts one feed row per remaining user with ON CONFLICT (user_id, dedup_key) DO NOTHING. If the user already has this event, it stops there.
  5. If the row is new and the user opted into browser notifications, sends a web push immediately.

Email and SMS are not sent here. They are left for the worker.

The four channels

ChannelHow it is sentWhen
In-appA row in the user's feed, read with an unread countAlways. It is the floor and cannot be turned off
Web pushVAPID-signed push to each of the user's registered browser subscriptionsImmediately, if opted in
EmailSMTP through nodemailerOn the next worker poll, or batched into a digest
SMSTwilio's REST messages endpoint, a form POST with basic auth and no SDKOn the next worker poll

Each outbound channel is a no-op when it is not configured. A deployment with no mail server, push keys or SMS account still runs in-app alerts in full.

Web push needs three pieces. The server exposes its VAPID public key, which the browser uses to subscribe, and stores each subscription's endpoint and keys per user. Sending loops over a user's subscriptions, and a 404 or 410 from the push service means the browser has dropped that subscription, so it is deleted rather than retried forever. On the client, a service worker of about thirty lines handles two events: on push it shows a notification with the title, body and link, and on notificationclick it focuses an open Connect tab and navigates it to the link, or opens a new window if none is open.

Preferences, quiet hours and digests

Preferences are per user and per category. There are seven categories: schedule, sync, connection, metric, recording, Info Sheet and workspace. For each, a user sets browser, email and SMS on or off, a minimum severity, and an optional mute-until time. Delivery beyond in-app is opt-in per person.

A separate settings row holds a timezone, a quiet window (10 p.m. to 7 a.m. by default), a digest mode (off, daily or weekly), and daily caps for email and SMS. The quiet-hours check runs in the user's own timezone, handles windows that wrap past midnight, and falls back to UTC if the stored timezone is unknown rather than throwing.

The rules for each email or SMS:

  • Critical bypasses everything. It ignores quiet hours, caps and digests. A critical path slip reaches you at 2 a.m. if you opted into SMS for schedule alerts.
  • Non-critical respects quiet hours and the rolling 24-hour cap.
  • Digest users get non-critical email batched into one message per day or week, held until they are outside quiet hours. An empty window still resets its timer so the worker does not re-scan that user every poll.

How the worker delivers

All of this runs in Connect's background worker. On each two-second poll it runs metric evaluations, escalations, webhook deliveries, email and SMS deliveries, and digests, each with a small per-poll limit and its own error handling, so a mail server outage does not stop alert evaluation or syncs.

The email and SMS queue is not a separate table. It is the feed itself. Each feed row has an email-sent and an SMS-sent timestamp, and the pending query joins feed rows to the user's preferences and settings to find rows a user wants on a channel that has not been stamped. That keeps the feed and the delivery state in one place: no dual write, and the question "did this person get emailed about this?" is a single column.

Each send attempt stamps its channel, which keeps the queue moving: one dead address or a rejected phone number never holds up the people behind it. The next step on our list is a per-row delivery ledger, which adds delivery receipts and per-attempt history for teams that need to prove a message landed.

Why this matters if you're building something similar

  • Split "something happened" from "who hears about it." One delivery gate means preferences, mutes, severity floors and dedup are implemented once, not per source.
  • Dedup at the database. A unique constraint on the slip ledger and on (user, dedup key) in the feed makes retries and replays harmless.
  • Fire on the rising edge, resolve on recovery. Streaks plus "already open" checks are what keep a metric alert from becoming spam.
  • Never interpolate user or model input into SQL. Bind field names as parameters and whitelist operators, and a model-written rule becomes safe to run.
  • Let AI draft rules, not save them. Coerce, validate, then require a person to accept.
  • Make critical the only thing that breaks quiet hours, and make every outbound channel optional.

Where to go next

Need alerting wired into your own Procore or scheduling data? Plan your build.

Frequently asked questions

What triggers a schedule slip alert?

When a new schedule capture is projected, it is compared to the previous capture. A later forecast finish raises a critical path slip alert, critical at a week or more and a warning below that, and newly critical activities raise their own alert. A unique ledger key per schedule, kind and snapshot prevents duplicates.

Can users create alerts in plain English?

Yes. A single-shot Alert Drafter turns a request into a rule draft, and a conversational Alert Assistant grounds proposals in the workspace's real connections and fields. Both go through the same validation, and a person accepts the rule before it is created.

Which delivery channels are supported?

In-app is always on. Users can opt into web push, email over SMTP and SMS through Twilio per notification category. Each outbound channel is a no-op when it is not configured, so in-app alerts work on any deployment.

How do quiet hours and digests work?

Each user sets a timezone, a quiet window, daily caps and a digest mode. Non-critical email and SMS wait for the quiet window to end and respect the caps, digest users get non-critical email batched daily or weekly, and critical alerts always go out immediately.

Are changes to alert rules audited?

Yes. Creating, updating, deleting or snoozing a rule writes a row to an alert rule audit table with the user, action and detail, so it is always possible to see who changed who gets notified.

Next step

Want this workflow automated?

Tell us the handoff, approval, or reminder your team repeats. We'll scope the automation, the approvals it needs, and who owns it.

Prefer email? charley@buildflows.ai

Get the next guide in your inbox

Field Notes: practical guides and new walkthroughs, about once a month.

Field Notes

Practical guides and new walkthroughs on construction data and automation, roughly monthly.

Keep learning