60 min readAutonomous agents

Implementation Guide: Track weather forecasts and proactively reschedule outdoor work crews

Step-by-step implementation guide for deploying AI to track weather forecasts and proactively reschedule outdoor work crews for Construction & Contractors clients.

Hardware Procurement

Edge Server for Self-Hosted n8n (Optional)

NUC 13 Pro

IntelRNUC13ANHI50001 (i5-1340P, 16GB RAM, 512GB SSD)Qty: 1

$650 per unit (MSP cost) / $900 suggested resale

Optional on-premises runtime for the n8n agent if the client requires data sovereignty or has unreliable internet. Runs Docker containers for n8n, PostgreSQL, and the agent scheduler. Only needed if cloud-hosted n8n is not acceptable to the client.

Ruggedized Tablets for Crew Leads

Ruggedized Tablet for Crew Leads

SamsungGalaxy Tab A9+ (SM-X210) with OtterBox Defender caseQty: 5

$280 per unit with case (MSP cost) / $400 suggested resale per unit

Primary notification and schedule confirmation endpoint for field crew leads. Displays rescheduled work assignments, weather alerts, and provides a confirmation button for the crew lead to acknowledge schedule changes. Quantity assumes 5 crew leads; adjust per client.

On-Site Weather Station (Premium Option)

WS-5000 Ultrasonic Professional Weather Station

Ambient WeatherWS-5000Qty: 1

$450 per unit (MSP cost) / $700 suggested resale

Hyper-local weather monitoring for high-value job sites (e.g., large commercial projects). Provides real-time wind speed, temperature, humidity, and precipitation data directly from the site, supplementing API forecast data. Connected via WiFi to upload readings to Ambient Weather Network API for agent consumption. Only recommended for projects exceeding $1M where weather precision directly impacts pour schedules or crane operations.

Software Procurement

n8n Cloud Pro

n8n GmbHSaaS

$50/month for 10,000 executions (MSP cost) / $125/month suggested resale

Primary workflow orchestration platform. Hosts all agent workflows including weather polling, AI decision logic, schedule API connectors, notification dispatching, and confirmation tracking. The Pro tier supports 10,000 executions/month which is sufficient for 15–20 job sites polled 4x daily with associated decision and notification workflows.

Visual Crossing Weather API

Visual CrossingAPI usage-based

$35/month for full historical + forecast access (MSP cost) / $85/month suggested resale

Primary weather data source. Provides 15-day hourly forecasts, historical weather data for pattern analysis, and severe weather alerts. Covers temperature, precipitation probability, wind speed, humidity, UV index, and lightning risk. Best price-to-feature ratio for SMB construction use cases. Each API call returns comprehensive data at $0.0001 per record.

Custom enterprise pricing, estimated $100–$300/month (MSP cost) / $175–$500/month suggested resale

Alternative weather data source with built-in operational alerting rules engine. Allows setting trigger conditions (e.g., wind > 25 mph, precipitation probability > 60%) that generate webhook callbacks, reducing polling frequency and enabling event-driven architecture. Preferred for clients with 20+ active sites or requiring minute-level forecast resolution.

OpenAI API (GPT-4.1-mini)

OpenAIGPT-4.1-mini

$0.40/million input tokens, $1.60/million output tokens; estimated $2–$10/month per client (MSP cost) / $25–$50/month bundled suggested resale

AI decision engine. Evaluates weather forecast data against crew schedules, activity types, and configurable thresholds to determine whether rescheduling is needed. Generates human-readable rescheduling explanations for crew notifications. GPT-4.1-mini provides strong function-calling support at negligible cost for this volume.

Twilio Programmable SMS

TwilioUsage-based API

$0.0083/message + $1.15/month per phone number; estimated $15–$40/month per client (MSP cost) / $45–$75/month suggested resale

SMS notification delivery to crew leads and workers. Sends rescheduling alerts, weather warnings, OSHA heat advisories, and schedule confirmation requests. SMS chosen over app-based notifications because construction workers reliably receive SMS regardless of smartphone type or installed apps.

Twilio SendGrid

TwilioSaaS - Free tierQty: Up to 100 emails/day

Free (MSP cost) / bundled into service

Email backup notification channel for office staff, project managers, and clients. Sends daily weather impact summaries, weekly rescheduling reports, and weather delay documentation packages.

PostgreSQL Database (Cloud-hosted)

Amazon Web ServicesRDS db.t3.microQty: 1

$15–$30/month (MSP cost) / bundled into managed service fee

Persistent storage for job site definitions, crew assignments, weather decision logs, notification delivery receipts, and historical weather data. Required for audit trail compliance and delay claim documentation. Can also use Supabase free tier for smaller deployments.

Microsoft 365 Business Basic

MicrosoftPer-seat SaaS

$6/user/month (MSP cost) / $12/user/month suggested resale

Microsoft Teams integration for office-to-field communication channel. Agent posts weather alerts and schedule changes to dedicated Teams channels. Also provides SharePoint for storing weather delay documentation. Only needed if client does not already have M365.

Prerequisites

  • Client must have an active construction project management platform with API access: Procore (REST API), Buildertrend (webhooks/Zapier), Jobber (GraphQL API), or equivalent. Paper-based scheduling cannot be integrated.
  • All crew leads must have smartphones (Android 10+ or iOS 15+) capable of receiving SMS messages. At minimum, the crew lead for each active crew must be reachable via SMS.
  • Client must provide a complete list of active job site addresses or GPS coordinates for weather location lookup. Each site needs a street address or lat/long pair.
  • Client must categorize all scheduled activity types as either 'outdoor-weather-sensitive' (concrete pour, roofing, grading, exterior painting, steel erection, crane operations) or 'indoor/weather-independent' (interior framing, electrical rough-in, plumbing, drywall finishing). This classification drives the agent's decision logic.
  • Client must designate a project coordinator or superintendent as the primary point of contact who can define weather thresholds per activity type (e.g., no concrete pours below 40°F or above 90°F, no crane operations above 30 mph winds).
  • Reliable internet connectivity at the main office (minimum 25 Mbps down / 5 Mbps up). Job sites require LTE/5G cell coverage for crew SMS delivery.
  • Client must provide crew lead phone numbers and crew-to-site assignment mappings in a structured format (spreadsheet at minimum, API-accessible system preferred).
  • A valid credit card or payment method for weather API, OpenAI API, and Twilio accounts. MSP typically provisions these under their own master accounts and bills the client.
  • Client must have administrative access to their PM platform to create API keys or OAuth applications for the integration.
  • If the client operates in California, confirm compliance with Cal/OSHA heat illness prevention standards (indoor threshold 82°F, outdoor threshold 80°F) and configure the agent's heat thresholds accordingly.

Installation Steps

Step 1: Provision Cloud Accounts and API Keys

Create and configure all required cloud service accounts. This establishes the foundation infrastructure before any workflow building begins. Use the MSP's master billing accounts where possible to maintain control and enable multi-client management.

1
Create n8n Cloud account at https://app.n8n.cloud/register — Select Pro plan ($50/month for 10,000 executions). Note the instance URL: https://<msp-instance>.app.n8n.cloud
2
Register for Visual Crossing Weather API at https://www.visualcrossing.com/sign-up — After registration, navigate to Account > API Key. Copy the API key and store securely.
3
Create OpenAI API account and generate key at https://platform.openai.com/signup — Navigate to API Keys > Create new secret key. Set usage limits: $20/month hard cap initially.
4
Create Twilio account at https://www.twilio.com/try-twilio — Purchase a local phone number ($1.15/month). Navigate to Console > Account SID and Auth Token.
5
Provision PostgreSQL database — Choose Option A (AWS RDS) or Option B (Supabase free tier for small deployments) at https://supabase.com/dashboard
Set Visual Crossing API key environment variable
bash
export VISUAL_CROSSING_API_KEY='your-key-here'
Set OpenAI API key environment variable
bash
export OPENAI_API_KEY='sk-your-key-here'
Set Twilio credentials as environment variables
bash
export TWILIO_ACCOUNT_SID='your-sid'
export TWILIO_AUTH_TOKEN='your-token'
export TWILIO_PHONE_NUMBER='+1XXXXXXXXXX'
Option A: Provision PostgreSQL database via AWS RDS
bash
aws rds create-db-instance \
  --db-instance-identifier weather-agent-db \
  --db-instance-class db.t3.micro \
  --engine postgres \
  --engine-version 16.3 \
  --master-username mspadmin \
  --master-user-password '<strong-password>' \
  --allocated-storage 20 \
  --backup-retention-period 30
Note

Store all API keys in a password manager (1Password, Bitwarden) under a dedicated vault for this client. Never commit keys to version control. Set spending alerts on OpenAI at $10 and $20 thresholds. For Twilio, enable geo-permissions to restrict SMS to US/Canada only to prevent fraud.

Step 2: Initialize Database Schema

Create the PostgreSQL database tables that store job sites, crew assignments, weather thresholds, decision logs, and notification records. This schema provides the persistent state the agent needs and creates the audit trail required for contract delay claims.

1
Connect to PostgreSQL: psql -h <rds-endpoint> -U mspadmin -d weatheragent
PostgreSQL schema: all tables, default threshold seed data, and indexes
sql
CREATE TABLE job_sites (
  id SERIAL PRIMARY KEY,
  client_id VARCHAR(50) NOT NULL,
  site_name VARCHAR(255) NOT NULL,
  address TEXT NOT NULL,
  latitude DECIMAL(10, 7),
  longitude DECIMAL(10, 7),
  is_active BOOLEAN DEFAULT true,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE crews (
  id SERIAL PRIMARY KEY,
  crew_name VARCHAR(100) NOT NULL,
  lead_name VARCHAR(100) NOT NULL,
  lead_phone VARCHAR(20) NOT NULL,
  lead_email VARCHAR(255),
  crew_size INTEGER DEFAULT 1,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE schedule_entries (
  id SERIAL PRIMARY KEY,
  job_site_id INTEGER REFERENCES job_sites(id),
  crew_id INTEGER REFERENCES crews(id),
  activity_type VARCHAR(100) NOT NULL,
  is_outdoor_sensitive BOOLEAN DEFAULT true,
  scheduled_date DATE NOT NULL,
  scheduled_start_time TIME,
  scheduled_end_time TIME,
  status VARCHAR(30) DEFAULT 'scheduled',
  external_pm_id VARCHAR(100),
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE weather_thresholds (
  id SERIAL PRIMARY KEY,
  activity_type VARCHAR(100) NOT NULL,
  max_wind_mph DECIMAL(5,1) DEFAULT 25.0,
  max_precip_probability DECIMAL(5,2) DEFAULT 0.50,
  min_temp_f DECIMAL(5,1) DEFAULT 32.0,
  max_temp_f DECIMAL(5,1) DEFAULT 100.0,
  max_heat_index_f DECIMAL(5,1) DEFAULT 90.0,
  lightning_risk_block BOOLEAN DEFAULT true,
  max_snow_inches DECIMAL(5,1) DEFAULT 1.0,
  notes TEXT,
  created_at TIMESTAMP DEFAULT NOW()
);

CREATE TABLE weather_checks (
  id SERIAL PRIMARY KEY,
  job_site_id INTEGER REFERENCES job_sites(id),
  check_timestamp TIMESTAMP DEFAULT NOW(),
  forecast_date DATE NOT NULL,
  temperature_f DECIMAL(5,1),
  feels_like_f DECIMAL(5,1),
  wind_speed_mph DECIMAL(5,1),
  wind_gust_mph DECIMAL(5,1),
  precip_probability DECIMAL(5,2),
  precip_type VARCHAR(30),
  snow_inches DECIMAL(5,1),
  humidity DECIMAL(5,1),
  uv_index DECIMAL(4,1),
  conditions VARCHAR(255),
  severe_alerts TEXT,
  raw_api_response JSONB
);

CREATE TABLE reschedule_decisions (
  id SERIAL PRIMARY KEY,
  schedule_entry_id INTEGER REFERENCES schedule_entries(id),
  weather_check_id INTEGER REFERENCES weather_checks(id),
  decision VARCHAR(30) NOT NULL,
  reason TEXT NOT NULL,
  ai_explanation TEXT,
  original_date DATE,
  new_date DATE,
  decided_at TIMESTAMP DEFAULT NOW(),
  confirmed_by_lead BOOLEAN DEFAULT false,
  confirmed_at TIMESTAMP
);

CREATE TABLE notifications (
  id SERIAL PRIMARY KEY,
  reschedule_decision_id INTEGER REFERENCES reschedule_decisions(id),
  recipient_phone VARCHAR(20),
  recipient_name VARCHAR(100),
  message_body TEXT,
  channel VARCHAR(20) DEFAULT 'sms',
  twilio_sid VARCHAR(100),
  delivery_status VARCHAR(30),
  sent_at TIMESTAMP DEFAULT NOW(),
  delivered_at TIMESTAMP
);

-- Insert default weather thresholds
INSERT INTO weather_thresholds (activity_type, max_wind_mph, max_precip_probability, min_temp_f, max_temp_f, max_heat_index_f, lightning_risk_block, notes) VALUES
('concrete_pour', 20.0, 0.40, 40.0, 95.0, 90.0, true, 'Concrete requires strict temperature and moisture control'),
('roofing', 25.0, 0.30, 35.0, 100.0, 95.0, true, 'Wind and rain are primary risks; steep slope work hazardous when wet'),
('exterior_painting', 15.0, 0.20, 50.0, 95.0, 90.0, false, 'Paint requires dry conditions and moderate temps for proper adhesion'),
('grading_excavation', 30.0, 0.50, 20.0, 105.0, 95.0, true, 'Heavy equipment can operate in moderate conditions but not saturated soil'),
('steel_erection', 25.0, 0.40, 20.0, 100.0, 95.0, true, 'Wind is primary concern for crane and steel placement'),
('crane_operations', 20.0, 0.30, 15.0, 105.0, 95.0, true, 'Strict wind limits per crane manufacturer specifications'),
('framing', 30.0, 0.50, 25.0, 100.0, 95.0, true, 'Moderate weather tolerance; lightning is primary safety risk'),
('landscaping', 25.0, 0.40, 35.0, 100.0, 95.0, false, 'Can work in light rain but not heavy precipitation'),
('general_outdoor', 25.0, 0.50, 32.0, 100.0, 90.0, true, 'Default thresholds for unspecified outdoor work');

CREATE INDEX idx_schedule_date ON schedule_entries(scheduled_date);
CREATE INDEX idx_weather_checks_site_date ON weather_checks(job_site_id, forecast_date);
CREATE INDEX idx_decisions_date ON reschedule_decisions(decided_at);
Note

Adjust default weather thresholds during the client discovery session. These defaults are conservative starting points. The superintendent should review and approve all threshold values before go-live. For Cal/OSHA compliance, set max_heat_index_f to 80.0 for California clients. Back up the database daily with 30-day retention.

Step 3: Configure n8n Instance and Install Required Nodes

Set up the n8n Cloud instance with required credentials, community nodes, and environment variables. This prepares the orchestration platform for workflow deployment.

1
Configure credentials in n8n Settings > Credentials:
2
OpenAI API: Add new credential type 'OpenAI API' — API Key: <your OpenAI API key>, Organization: (leave blank for default)
3
PostgreSQL: Add new credential type 'Postgres' — Host: <rds-endpoint>, Database: weatheragent, User: mspadmin, Password: <password>, Port: 5432, SSL: Require
4
Twilio: Add new credential type 'Twilio API' — Account SID: <your SID>, Auth Token: <your token>
5
HTTP Header Auth (for Visual Crossing): Name: vc-api-key, Value: <your Visual Crossing API key>
6
Configure environment variables in n8n Settings:
7
If self-hosting n8n instead of cloud, run the Docker setup below
Environment variables to configure in n8n Settings
bash
CLIENT_NAME=<client company name>
DEFAULT_TIMEZONE=America/New_York
ALERT_ESCALATION_PHONE=+1XXXXXXXXXX
FORECAST_HOURS_AHEAD=24
POLLING_INTERVAL_HOURS=6
Self-hosted n8n Docker setup (use only if not using n8n Cloud)
bash
docker volume create n8n_data
docker run -d --name n8n \
  -p 5678:5678 \
  -v n8n_data:/home/node/.n8n \
  -e N8N_BASIC_AUTH_ACTIVE=true \
  -e N8N_BASIC_AUTH_USER=admin \
  -e N8N_BASIC_AUTH_PASSWORD='<strong-password>' \
  -e GENERIC_TIMEZONE='America/New_York' \
  -e N8N_ENCRYPTION_KEY='<generate-random-32-char-string>' \
  --restart unless-stopped \
  n8nio/n8n:latest
Note

If using n8n Cloud, credentials are encrypted at rest by n8n. If self-hosting, ensure the N8N_ENCRYPTION_KEY is backed up separately — losing it means losing access to all stored credentials. Set the timezone to match the client's primary operating timezone. For multi-timezone clients (e.g., national contractors), handle timezone conversion in the workflow logic.

Step 4: Import Client Data: Job Sites, Crews, and Schedules

Populate the database with the client's active job sites, crew rosters, and initial schedule data. This step bridges the client's existing operations into the agent's data model. Data can be imported from spreadsheets or pulled via API from their PM platform.

1
Option A: Import from CSV (for clients without API access) — Prepare job_sites.csv with columns: site_name, address, latitude, longitude. Prepare crews.csv with columns: crew_name, lead_name, lead_phone, lead_email, crew_size. Use psql COPY command:
2
Option B: Pull from Procore API
3
Option C: Pull from Jobber GraphQL API
4
After import, verify geocoding for any sites missing lat/long using Visual Crossing's location resolution (it accepts addresses)
Option A: Import job sites and crews from CSV using psql COPY
bash
\copy job_sites(client_id, site_name, address, latitude, longitude) FROM 'job_sites.csv' WITH CSV HEADER;
\copy crews(crew_name, lead_name, lead_phone, lead_email, crew_size) FROM 'crews.csv' WITH CSV HEADER;
Option B: Pull project data from Procore API
bash
curl -X GET 'https://api.procore.com/rest/v1.0/projects' \
  -H 'Authorization: Bearer <procore_access_token>' \
  -H 'Procore-Company-Id: <company_id>' | jq '.[].name, .[].address'
Option C: Pull job data from Jobber GraphQL API
bash
curl -X POST 'https://api.getjobber.com/api/graphql' \
  -H 'Authorization: Bearer <jobber_access_token>' \
  -H 'Content-Type: application/json' \
  -d '{"query": "{ jobs(first: 50) { nodes { title address { street city state postalCode } } } }"}'
Verify geocoding for sites missing lat/long using Visual Crossing
bash
curl 'https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/1234%20Main%20St%20Denver%20CO?key=<API_KEY>&include=current' | jq '.latitude, .longitude'
Note

GPS coordinates are preferred over addresses for weather API accuracy. If the client only provides addresses, Visual Crossing and OpenWeatherMap will geocode automatically, but verify the resolved location is correct (especially for rural job sites). For Procore integration, you need a Procore developer account and a service account or OAuth app configured in the client's Procore instance. Request the 'Project Management' scope at minimum.

Step 5: Build Core Weather Polling Workflow in n8n

Create the primary n8n workflow that runs on a cron schedule, fetches weather forecasts for all active job sites, and stores the data. This is the sensory input layer of the agent — it runs every 6 hours (4x/day) at 4:00 AM, 10:00 AM, 4:00 PM, and 10:00 PM to capture forecast updates throughout the day.

1
Workflow Structure: [Cron Trigger] → [Postgres: Get Active Sites] → [Loop: For Each Site] → [HTTP Request: Visual Crossing API] → [Code: Parse Response] → [Postgres: Store Weather Data] → [End Loop]
2
Node 1: Cron Trigger — Type: Schedule Trigger | Rule: Every 6 hours at minutes 0 (0 4,10,16,22 * * *)
3
Node 2: Postgres - Get Active Sites — Operation: Execute Query
4
Node 3: Loop Over Items (Split In Batches) — Batch Size: 1 (to respect API rate limits) | Add a 1-second Wait node between iterations
5
Node 4: HTTP Request - Visual Crossing — Method: GET
6
Node 5: Code Node - Parse Weather Response — (See custom_ai_components for full implementation)
7
Node 6: Postgres - Insert Weather Check — Operation: Execute Query
Node 2: Postgres — Get Active Sites query
sql
SELECT id, site_name, latitude, longitude, address FROM job_sites WHERE is_active = true
Node 4: HTTP Request — Visual Crossing API URL
text
https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/{{$json.latitude}},{{$json.longitude}}/next3days
Node 4: HTTP Request — Visual Crossing query parameters
text
key: {{$credentials.vcApiKey}}
unitGroup: us
include: hours,alerts,current
elements: datetime,temp,feelslike,humidity,precip,precipprob,preciptype,snow,windspeed,windgust,uvindex,conditions,description,severerisk
Node 6: Postgres — Insert Weather Check query
sql
INSERT INTO weather_checks (job_site_id, forecast_date, temperature_f, ...)
  VALUES ($1, $2, $3, ...) RETURNING id
Note

Visual Crossing's free tier allows 1,000 records/day. Each 3-day hourly forecast call returns ~72 records. For 10 sites polled 4x/day, that's ~2,880 records — you'll need the $35/month paid tier. Add a 1-second delay between API calls to avoid rate limiting. The workflow should also handle API errors gracefully with a retry mechanism (n8n has built-in retry on failure).

Step 6: Build AI Decision Engine Workflow in n8n

Create the workflow that evaluates stored weather forecasts against crew schedules and weather thresholds, using GPT-4.1-mini to make intelligent rescheduling decisions. This workflow triggers after each weather polling run and evaluates the next 24–48 hours of scheduled work.

1
Import via n8n UI > Import Workflow to create the AI Decision Engine workflow
2
Workflow Structure: [Trigger: After Weather Poll] → [Postgres: Get Tomorrow's Schedules] → [Postgres: Get Weather Data for Each Site] → [Postgres: Get Thresholds] → [Code: Prepare AI Context] → [OpenAI: Evaluate & Decide] → [Code: Parse AI Response] → [IF: Reschedule Needed?] → YES → [Postgres: Log Decision] → [Trigger: Notification Workflow] / NO → [Postgres: Log All-Clear] → [End]
  • Node: OpenAI Chat Model
  • Model: gpt-4.1-mini
  • Temperature: 0.1 (low creativity, high consistency)
  • System Prompt: see custom_ai_components for full prompt
  • Response Format: JSON mode enabled
  • Max Tokens: 1000

The AI receives a structured context including the site name and location, scheduled activities for the next 24–48 hours, hourly weather forecast for those hours, applicable thresholds for each activity type, and crew assignments and sizes.

AI decision response payload returned by GPT-4.1-mini
json
{
  'site_name': 'Oak Ridge Subdivision Lot 14',
  'decision': 'reschedule' | 'proceed' | 'monitor',
  'affected_activities': [...],
  'reason': 'human-readable explanation',
  'risk_level': 'high' | 'medium' | 'low',
  'recommended_reschedule_date': '2025-01-20',
  'osha_heat_alert': true/false,
  'specific_concerns': [...]
}
Note

Set OpenAI temperature to 0.1 or lower for consistent, reliable decisions. Enable JSON mode to ensure parseable responses. The AI does NOT autonomously execute schedule changes — it produces a decision that the notification and rescheduling workflows act on. This is a deliberate safety design: the human (crew lead) confirms the change. Include a fallback rule: if the OpenAI API is unreachable, fall back to simple threshold-based rules (no AI) to ensure weather safety alerts still go out.

Step 7: Build Notification and Confirmation Workflow

Create the workflow that sends SMS notifications to affected crew leads, posts alerts to Microsoft Teams, sends email summaries to the office, and tracks confirmation responses. This is the action layer that closes the loop between AI decision and human acknowledgment.

1
n8n Workflow Structure: [Trigger: Decision Logged] → [Postgres: Get Crew + Contact Info] → [Switch: Decision Type] → RESCHEDULE: [Twilio SMS: Crew Lead] + [Teams: Channel Post] + [Email: PM] | MONITOR: [Twilio SMS: Crew Lead (advisory)] + [Teams: Channel Post] | OSHA_HEAT: [Twilio SMS: ALL Crew Members] + [Teams: Urgent] + [Email: Safety Officer]
2
Configure Twilio SMS Node: From = {{$env.TWILIO_PHONE_NUMBER}}, To = {{$json.lead_phone}}, Body = (see custom_ai_components for message templates)
3
Create a Webhook SMS Reply Handler endpoint in n8n to receive Twilio incoming SMS
4
Configure Twilio phone number webhook to point to the n8n webhook URL
5
Parse SMS replies: 'OK', 'YES', 'CONFIRM' → mark decision as confirmed; 'NO', 'REJECT', 'CALL' → escalate to superintendent
6
In Microsoft Teams, create an Incoming Webhook connector in the designated channel
7
Configure n8n HTTP Request node to POST adaptive card JSON to the Teams webhook URL
8
Configure Escalation Timer: if no crew lead confirmation within 30 minutes, send follow-up SMS; if no confirmation within 60 minutes, call superintendent via Twilio Voice API
Webhook endpoints and Twilio Voice escalation call configuration
javascript
# [Webhook: SMS Reply Handler]
# URL: https://<n8n-instance>/webhook/twilio-reply

# [Teams Incoming Webhook]
# URL: https://outlook.office.com/webhook/<team-specific-url>

# [Twilio Voice Escalation]
twilio.calls.create({
  to: superintendent_phone,
  from: twilio_number,
  twiml: '<Response><Say>Weather alert: Crew lead for site Oak Ridge has not confirmed schedule change. Please intervene.</Say></Response>'
})
Note

Configure Twilio webhook URL for inbound SMS in the Twilio Console under Phone Numbers > Active Numbers > your number > Messaging > 'A Message Comes In' webhook. Test the reply parsing thoroughly — construction workers may reply with 'ok', 'OK', 'Ok ', 'yes', 'got it', etc. Build a fuzzy matching function. OSHA heat alerts should go to ALL workers on affected crews, not just the lead. Store the superintendent's personal phone number for voice escalation.

Step 8: Build Schedule Sync Integration

Create the bidirectional integration between the agent's rescheduling decisions and the client's project management platform. This ensures that when the AI reschedules a crew, the change is reflected in Procore, Buildertrend, or Jobber — and that new schedule entries in the PM platform are picked up by the agent.

Procore Integration

1
Create a Procore App at https://developers.procore.com
2
App Type: Data Connection (Service Account)
3
Required Permissions: Project Management (read/write), Daily Log (write)
1
n8n HTTP Request node — Update Procore Schedule: Method: PATCH, URL: https://api.procore.com/rest/v1.0/schedule_tasks/{{task_id}}
2
n8n HTTP Request node — Create Procore Daily Log Entry: Method: POST, URL: https://api.procore.com/rest/v1.0/projects/{{project_id}}/daily_logs
Procore — Update Schedule Task (PATCH)
http
PATCH https://api.procore.com/rest/v1.0/schedule_tasks/{{task_id}}
Authorization: Bearer {{$credentials.procoreToken}}
Procore-Company-Id: {{$env.PROCORE_COMPANY_ID}}

{
  "scheduled_start_date": "{{new_date}}",
  "scheduled_end_date": "{{new_end_date}}",
  "notes": "Rescheduled by Weather Agent: {{reason}}"
}
Procore — Create Daily Log Entry (POST)
http
POST https://api.procore.com/rest/v1.0/projects/{{project_id}}/daily_logs
Authorization: Bearer {{$credentials.procoreToken}}
Procore-Company-Id: {{$env.PROCORE_COMPANY_ID}}

{
  "date": "{{original_date}}",
  "weather_delay": true,
  "notes": "{{ai_explanation}}",
  "weather_conditions": "{{conditions_summary}}"
}

Buildertrend Integration

Buildertrend does not have a public REST API. Use Zapier or Make.com as middleware.

  • Option 1: n8n Webhook → Zapier Catch Hook → Buildertrend Schedule Update
  • Option 2: Use Buildertrend's email-to-task feature for schedule updates

Jobber Integration

Jobber GraphQL API
graphql
# Reschedule Job (POST to https://api.getjobber.com/api/graphql)

mutation {
  jobReschedule(jobId: "{{jobber_job_id}}", input: {
    startAt: "{{new_datetime_iso}}"
    endAt: "{{new_end_datetime_iso}}"
  }) {
    job { id title startAt endAt }
    userErrors { message }
  }
}

Google Calendar Fallback

  • For clients without PM platform APIs, use n8n's Google Calendar node to update crew calendars
  • Create shared calendars per crew
  • Update events with rescheduled times and weather notes
Note

Procore OAuth tokens expire; implement token refresh in n8n using the OAuth2 credential type with automatic refresh. Buildertrend's lack of a public API is its biggest limitation — the Zapier middleware approach works but adds latency and cost ($29.99/month for Zapier Professional). For clients on Buildertrend, consider migrating schedule management to a Google Calendar-based system that the agent manages directly, with manual sync to Buildertrend by office staff. Always write a weather delay note to the PM platform's daily log — this is critical for delay claim documentation.

Step 9: Configure OSHA Heat Illness Compliance Module

Set up dedicated heat index monitoring and automatic crew safety alerts that comply with proposed federal OSHA heat standards and existing Cal/OSHA regulations. This is both a safety feature and a significant compliance selling point.

1
Heat Index Thresholds Configuration — Insert or update thresholds in the database
2
Apply Federal OSHA Proposed Thresholds nationwide: Initial Heat Trigger at 80°F heat index, High Heat Trigger at 90°F heat index
3
Apply Cal/OSHA Specific Thresholds for California clients: Outdoor trigger at 80°F, High heat procedures at 95°F
Database threshold configuration for Federal OSHA and Cal/OSHA heat index triggers
sql
UPDATE weather_thresholds SET
  max_heat_index_f = 80.0
WHERE activity_type IN ('general_outdoor', 'roofing', 'concrete_pour', 'exterior_painting');

INSERT INTO weather_thresholds (activity_type, max_heat_index_f, notes)
VALUES ('cal_osha_outdoor', 80.0, 'Cal/OSHA outdoor heat trigger - mandatory water, shade, rest'),
       ('cal_osha_high_heat', 95.0, 'Cal/OSHA high heat - mandatory buddy system, pre-shift meetings');
1
In the n8n AI Decision workflow, add a parallel branch: [Weather Data] → [Code: Calculate Heat Index] → [IF: Heat Index >= 80°F]
2
IF YES and heat index < 90°F → [SMS: Water/shade reminder to all crew members]
3
IF YES and heat index >= 90°F → [SMS: MANDATORY rest schedule + buddy system alert]
4
IF YES and heat index >= 105°F → [SMS: STOP WORK ORDER to crew lead + superintendent]
Heat Index Calculation using Rothfusz regression — add as a Code node in n8n
javascript
const T = $json.temperature_f;
const R = $json.humidity;
let HI = 0.5 * (T + 61.0 + ((T-68.0)*1.2) + (R*0.094));
if (HI >= 80) {
  HI = -42.379 + 2.04901523*T + 10.14333127*R
    - 0.22475541*T*R - 0.00683783*T*T - 0.05481717*R*R
    + 0.00122874*T*T*R + 0.00085282*T*R*R - 0.00000199*T*T*R*R;
}
return [{json: {...$json, heat_index_f: Math.round(HI * 10) / 10}}];
Note

The OSHA heat standard is currently a proposed rule (NPRM published August 30, 2024) and may be finalized in 2025-2026. Cal/OSHA standards are already in effect. Implementing heat monitoring proactively positions the client ahead of regulation. Heat alerts should go to ALL workers on the crew, not just the crew lead — use a broadcast SMS to all registered phone numbers for that crew. Document all heat alerts sent for OSHA compliance records. Fines for heat violations can reach $14,000+ per violation.

Step 10: Deploy Monitoring Dashboard and Alerting

Set up a monitoring dashboard so the MSP can track agent health, API costs, decision accuracy, and client satisfaction. This enables proactive managed service delivery and early detection of issues.

Option A: Configure n8n Error Handler Workflow

1
Navigate to Settings > Executions in n8n Cloud Pro to access the built-in execution log
2
Filter executions by workflow, status (success/error), and date range
3
Create a new workflow named 'Error Handler'
4
Add an Error Trigger node as the trigger (catches failures from other workflows)
5
Add actions: Send SMS to MSP on-call + Email to MSP NOC
6
In each production workflow, go to Settings > Error Workflow and select 'Error Handler'

Option B: Launch Grafana with Docker

Start Grafana OSS container on port 3000
bash
docker run -d --name grafana -p 3000:3000 grafana/grafana-oss:latest
1
Add PostgreSQL as a data source pointing to the weatheragent database
2
Create a 'Weather checks per day' panel (line chart)
3
Create a 'Reschedule decisions by type' panel (pie chart)
4
Create a 'Notification delivery success rate' panel (gauge)
5
Create an 'API cost tracker' panel (calculated from weather_checks count × cost per call)
6
Create an 'Unconfirmed alerts' panel (table, filtered to last 24 hours)

Grafana Alerting Rules

1
Alert 1: If weather_checks count in last 12 hours = 0 → API may be down
2
Alert 2: If notification delivery_status = 'failed' count > 3 in 1 hour → Twilio issue
3
Alert 3: If unconfirmed reschedule_decisions older than 2 hours exist → escalate

Daily Agent Health Check Query

PostgreSQL query for 24-hour agent health summary — run on a schedule or used as a Grafana panel data source
sql
SELECT 
  (SELECT COUNT(*) FROM weather_checks WHERE check_timestamp > NOW() - INTERVAL '24 hours') as checks_24h,
  (SELECT COUNT(*) FROM reschedule_decisions WHERE decided_at > NOW() - INTERVAL '24 hours') as decisions_24h,
  (SELECT COUNT(*) FROM notifications WHERE sent_at > NOW() - INTERVAL '24 hours' AND delivery_status = 'delivered') as notifications_delivered_24h,
  (SELECT COUNT(*) FROM reschedule_decisions WHERE decided_at > NOW() - INTERVAL '24 hours' AND confirmed_by_lead = false) as unconfirmed_24h;
Note

For n8n Cloud deployments, the built-in execution log is sufficient for most MSPs. Grafana is recommended only for self-hosted deployments serving 5+ clients. Set up a weekly automated report email to the MSP account manager showing: total weather checks, reschedule decisions made, notification success rate, and estimated cost savings (crews not sent to sites unnecessarily × average mobilization cost). This data supports QBR presentations and renewal conversations.

Step 11: End-to-End Testing with Historical Weather Data

Before going live, validate the entire pipeline using historical weather data for the client's actual job sites. This tests the complete flow from weather ingestion through AI decision to notification delivery without waiting for actual bad weather.

1
Fetch historical weather data for a known bad-weather day — find a recent day with rain/storms at a client job site
2
Insert test schedule entry for that historical date
3
Manually trigger the weather polling workflow with the historical date — in n8n, use the 'Test Workflow' button with manual input overriding the date
4
Verify the AI decision workflow fires and produces a correct decision — check reschedule_decisions table
5
Verify SMS notification was sent (use a test phone number) — check Twilio Console > Monitor > Messaging for delivery status
6
Test the confirmation reply — reply 'OK' to the SMS from the test phone and verify reschedule_decisions.confirmed_by_lead = true
7
Test escalation path — insert a decision and do NOT reply to the SMS; after 30 minutes, verify follow-up SMS is sent; after 60 minutes, verify voice call is placed to superintendent
8
Test OSHA heat alert separately — use historical data from a day with heat index > 90°F and verify all crew members (not just lead) receive heat alert
Fetch historical hourly weather data for a specific job site and date
bash
curl 'https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/39.7392,-104.9903/2025-01-15?key=<API_KEY>&unitGroup=us&include=hours' | jq '.days[0].hours[] | {datetime, temp, windspeed, precipprob, conditions}'
Insert a test schedule entry for the historical bad-weather date
sql
INSERT INTO schedule_entries (job_site_id, crew_id, activity_type, is_outdoor_sensitive, scheduled_date, scheduled_start_time, scheduled_end_time, status)
VALUES (1, 1, 'concrete_pour', true, '2025-01-15', '07:00', '15:00', 'scheduled');
Verify AI decision was recorded in the reschedule_decisions table
sql
SELECT * FROM reschedule_decisions ORDER BY decided_at DESC LIMIT 5;
Note

Run at least 5 test scenarios: (1) clear weather, work proceeds; (2) heavy rain, full reschedule; (3) borderline conditions, monitor advisory; (4) extreme heat, OSHA alert; (5) lightning risk, immediate reschedule. Save test results as documentation for the client handoff. Use the MSP team's personal phones as test recipients before switching to actual crew numbers.

Custom AI Components

Weather Forecast Parser

Type: skill A code node for n8n that parses raw Visual Crossing API responses into the structured format needed by the decision engine. Extracts relevant weather parameters, calculates derived values (heat index, wind chill), and identifies the most impactful weather windows within scheduled work hours.

Implementation:

n8n Code Node: Weather Forecast Parser
javascript
// n8n Code Node: Weather Forecast Parser
// Input: Raw Visual Crossing API JSON response + schedule entry
// Output: Structured weather assessment for the scheduled work period

const weatherData = $input.first().json;
const scheduleEntries = $('Get Tomorrow Schedules').all();

const results = [];

for (const entry of scheduleEntries) {
  const siteWeather = weatherData;
  const startHour = parseInt(entry.json.scheduled_start_time.split(':')[0]);
  const endHour = parseInt(entry.json.scheduled_end_time.split(':')[0]);
  
  // Filter forecast hours to scheduled work window
  const workHours = siteWeather.days[0].hours.filter(h => {
    const hour = parseInt(h.datetime.split(':')[0]);
    return hour >= startHour && hour <= endHour;
  });
  
  if (workHours.length === 0) {
    results.push({ json: { ...entry.json, weather_assessment: 'NO_DATA', skip: true } });
    continue;
  }
  
  // Calculate aggregated weather metrics for the work window
  const maxTemp = Math.max(...workHours.map(h => h.temp));
  const minTemp = Math.min(...workHours.map(h => h.temp));
  const maxFeelsLike = Math.max(...workHours.map(h => h.feelslike));
  const maxWind = Math.max(...workHours.map(h => h.windspeed));
  const maxGust = Math.max(...workHours.map(h => h.windgust || h.windspeed));
  const maxPrecipProb = Math.max(...workHours.map(h => h.precipprob)) / 100;
  const avgHumidity = workHours.reduce((sum, h) => sum + h.humidity, 0) / workHours.length;
  const maxUV = Math.max(...workHours.map(h => h.uvindex || 0));
  const totalSnow = workHours.reduce((sum, h) => sum + (h.snow || 0), 0);
  const precipTypes = [...new Set(workHours.flatMap(h => h.preciptype || []))];
  const severeRisk = Math.max(...workHours.map(h => h.severerisk || 0));
  
  // Calculate Heat Index (Rothfusz regression)
  function calcHeatIndex(T, R) {
    let HI = 0.5 * (T + 61.0 + ((T - 68.0) * 1.2) + (R * 0.094));
    if (HI >= 80) {
      HI = -42.379 + 2.04901523 * T + 10.14333127 * R
        - 0.22475541 * T * R - 0.00683783 * T * T
        - 0.05481717 * R * R + 0.00122874 * T * T * R
        + 0.00085282 * T * R * R - 0.00000199 * T * T * R * R;
      // Adjustment for low humidity
      if (R < 13 && T >= 80 && T <= 112) {
        HI -= ((13 - R) / 4) * Math.sqrt((17 - Math.abs(T - 95)) / 17);
      }
      // Adjustment for high humidity
      if (R > 85 && T >= 80 && T <= 87) {
        HI += ((R - 85) / 10) * ((87 - T) / 5);
      }
    }
    return Math.round(HI * 10) / 10;
  }
  
  const maxHeatIndex = calcHeatIndex(maxTemp, avgHumidity);
  
  // Identify worst weather window
  const worstHour = workHours.reduce((worst, h) => {
    const score = (h.precipprob / 100) * 3 + (h.windspeed / 50) * 2 + ((h.severerisk || 0) / 100) * 5;
    const worstScore = (worst.precipprob / 100) * 3 + (worst.windspeed / 50) * 2 + ((worst.severerisk || 0) / 100) * 5;
    return score > worstScore ? h : worst;
  });
  
  // Compile conditions summary
  const conditions = [...new Set(workHours.map(h => h.conditions))].join(', ');
  
  results.push({
    json: {
      schedule_entry_id: entry.json.id,
      job_site_id: entry.json.job_site_id,
      site_name: entry.json.site_name,
      crew_id: entry.json.crew_id,
      crew_name: entry.json.crew_name,
      activity_type: entry.json.activity_type,
      is_outdoor_sensitive: entry.json.is_outdoor_sensitive,
      scheduled_date: entry.json.scheduled_date,
      work_window: `${entry.json.scheduled_start_time} - ${entry.json.scheduled_end_time}`,
      weather: {
        max_temp_f: maxTemp,
        min_temp_f: minTemp,
        max_feels_like_f: maxFeelsLike,
        max_heat_index_f: maxHeatIndex,
        max_wind_mph: maxWind,
        max_gust_mph: maxGust,
        max_precip_probability: maxPrecipProb,
        precip_types: precipTypes,
        total_snow_inches: totalSnow,
        avg_humidity: Math.round(avgHumidity),
        max_uv_index: maxUV,
        severe_risk_pct: severeRisk,
        conditions_summary: conditions,
        worst_hour: worstHour.datetime,
        worst_hour_conditions: worstHour.conditions
      },
      skip: false
    }
  });
}

return results;

Weather Rescheduling Decision Agent Prompt

Type: prompt

The system prompt and structured user prompt for GPT-4.1-mini that drives all rescheduling decisions. The prompt includes the role definition, decision framework, threshold awareness, output schema, and safety guardrails. It is designed for JSON mode output with function-calling-compatible structure.

Implementation:

System prompt and user prompt builder
javascript
// configure system prompt in n8n OpenAI node > System Message; user prompt
// is built dynamically in the n8n Code node

// SYSTEM PROMPT (configure in n8n OpenAI node > System Message)

const systemPrompt = `You are an autonomous weather safety and scheduling agent for a construction company. Your job is to evaluate weather forecasts against scheduled outdoor work and make intelligent rescheduling decisions.

## Your Decision Framework

For each scheduled activity, you must evaluate the weather forecast and return ONE of these decisions:

1. **PROCEED** - Weather is within safe thresholds. Work should continue as scheduled.
2. **MONITOR** - Weather is borderline. Work can start but crew lead should monitor conditions and be prepared to stop. Send advisory notice.
3. **RESCHEDULE** - Weather will prevent safe or productive work. Reschedule to the next available clear day.
4. **STOP_WORK_HEAT** - OSHA heat illness risk. Mandatory hydration, shade, and rest protocols. May require shortened work day or full reschedule.
5. **STOP_WORK_SEVERE** - Severe weather (lightning, tornado, extreme wind). Immediate safety stand-down required.

## Activity-Specific Thresholds

You will receive the applicable thresholds for each activity type. Use these as your primary decision criteria:
- If ANY threshold is exceeded during the scheduled work window, the activity should be rescheduled
- If thresholds are within 10% of being exceeded, issue a MONITOR advisory
- Lightning risk or severe weather alerts ALWAYS trigger STOP_WORK_SEVERE regardless of other conditions

## OSHA Heat Compliance Rules (MANDATORY)
- Heat Index >= 80°F: Recommend water breaks every 20 minutes, provide shade access
- Heat Index >= 90°F: STOP_WORK_HEAT - Mandatory rest/water schedule, buddy system required
- Heat Index >= 105°F: STOP_WORK_HEAT - Cease all outdoor work immediately

## Rescheduling Logic
When recommending a reschedule:
- Look at the next 3-5 calendar days of forecast data if available
- Recommend the nearest day where ALL thresholds are met
- If no clear day in the next 5 days, recommend the best available day and note the compromise
- Consider task dependencies: concrete pours need 2+ consecutive dry days for curing
- Prioritize: safety first, then productivity, then schedule impact

## Output Format
Always respond with valid JSON matching this exact schema:
{
  "decisions": [
    {
      "schedule_entry_id": <integer>,
      "site_name": "<string>",
      "activity_type": "<string>",
      "decision": "PROCEED" | "MONITOR" | "RESCHEDULE" | "STOP_WORK_HEAT" | "STOP_WORK_SEVERE",
      "risk_level": "low" | "medium" | "high" | "critical",
      "primary_concerns": ["<list of specific weather concerns>"],
      "reason": "<2-3 sentence plain-English explanation suitable for sending to a crew foreman via text message>",
      "recommended_new_date": "<YYYY-MM-DD or null if PROCEED>",
      "osha_heat_alert": <boolean>,
      "osha_heat_details": "<specific heat precautions required, or null>",
      "confidence": <0.0 to 1.0>
    }
  ],
  "overall_summary": "<1-2 sentence summary of all decisions for the office manager>"
}

## Safety Rules
- When in doubt, err on the side of caution (RESCHEDULE over PROCEED)
- Never recommend proceeding with work during lightning risk
- Always flag heat index concerns even if other conditions are favorable
- If forecast data seems incomplete or unreliable, recommend MONITOR and flag the data quality issue
`;

// USER PROMPT TEMPLATE (constructed dynamically per evaluation batch)
// This is built in the n8n Code node that prepares the AI context

function buildUserPrompt(weatherAssessments, thresholds, forecastDays) {
  let prompt = `## Current Evaluation Batch\n`;
  prompt += `Evaluation Time: ${new Date().toISOString()}\n`;
  prompt += `Client: {{$env.CLIENT_NAME}}\n\n`;
  
  for (const assessment of weatherAssessments) {
    prompt += `### Scheduled Activity\n`;
    prompt += `- Schedule Entry ID: ${assessment.schedule_entry_id}\n`;
    prompt += `- Site: ${assessment.site_name}\n`;
    prompt += `- Activity: ${assessment.activity_type}\n`;
    prompt += `- Crew: ${assessment.crew_name}\n`;
    prompt += `- Date: ${assessment.scheduled_date}\n`;
    prompt += `- Work Window: ${assessment.work_window}\n`;
    prompt += `- Outdoor Sensitive: ${assessment.is_outdoor_sensitive}\n\n`;
    
    // Get matching thresholds
    const t = thresholds.find(th => th.activity_type === assessment.activity_type) 
      || thresholds.find(th => th.activity_type === 'general_outdoor');
    
    prompt += `#### Applicable Thresholds for ${assessment.activity_type}\n`;
    prompt += `- Max Wind: ${t.max_wind_mph} mph\n`;
    prompt += `- Max Precip Probability: ${(t.max_precip_probability * 100).toFixed(0)}%\n`;
    prompt += `- Min Temperature: ${t.min_temp_f}°F\n`;
    prompt += `- Max Temperature: ${t.max_temp_f}°F\n`;
    prompt += `- Max Heat Index: ${t.max_heat_index_f}°F\n`;
    prompt += `- Lightning Blocks Work: ${t.lightning_risk_block}\n`;
    prompt += `- Max Snow: ${t.max_snow_inches} inches\n\n`;
    
    prompt += `#### Weather Forecast for Work Window\n`;
    prompt += `- Max Temperature: ${assessment.weather.max_temp_f}°F\n`;
    prompt += `- Min Temperature: ${assessment.weather.min_temp_f}°F\n`;
    prompt += `- Max Feels Like: ${assessment.weather.max_feels_like_f}°F\n`;
    prompt += `- Calculated Heat Index: ${assessment.weather.max_heat_index_f}°F\n`;
    prompt += `- Max Wind Speed: ${assessment.weather.max_wind_mph} mph\n`;
    prompt += `- Max Wind Gust: ${assessment.weather.max_gust_mph} mph\n`;
    prompt += `- Max Precipitation Probability: ${(assessment.weather.max_precip_probability * 100).toFixed(0)}%\n`;
    prompt += `- Precipitation Types: ${assessment.weather.precip_types.join(', ') || 'None'}\n`;
    prompt += `- Total Snow: ${assessment.weather.total_snow_inches} inches\n`;
    prompt += `- Humidity: ${assessment.weather.avg_humidity}%\n`;
    prompt += `- UV Index: ${assessment.weather.max_uv_index}\n`;
    prompt += `- Severe Risk: ${assessment.weather.severe_risk_pct}%\n`;
    prompt += `- Conditions: ${assessment.weather.conditions_summary}\n`;
    prompt += `- Worst Hour: ${assessment.weather.worst_hour} — ${assessment.weather.worst_hour_conditions}\n\n`;
  }
  
  if (forecastDays && forecastDays.length > 0) {
    prompt += `### Extended Forecast (for rescheduling suggestions)\n`;
    for (const day of forecastDays) {
      prompt += `- ${day.date}: High ${day.high}°F, Low ${day.low}°F, Wind ${day.wind} mph, Precip ${day.precip_prob}%, Conditions: ${day.conditions}\n`;
    }
    prompt += `\n`;
  }
  
  prompt += `Please evaluate all scheduled activities above and return your decisions as JSON.`;
  return prompt;
}

// Export for use in n8n
return { systemPrompt, buildUserPrompt };

SMS Notification Composer

Type: skill Generates context-appropriate SMS messages for different decision types. Messages are kept under 160 characters for single-segment SMS when possible, with longer messages for complex rescheduling. Includes reply instructions for confirmation tracking.

Implementation:

n8n Code Node: SMS Notification Composer
javascript
// n8n Code Node: SMS Notification Composer
// Input: reschedule decision object
// Output: formatted SMS message body per recipient type

const decision = $input.first().json;

function composeCrewLeadSMS(d) {
  switch (d.decision) {
    case 'PROCEED':
      return null; // No notification needed for proceed
      
    case 'MONITOR':
      return `⚠️ WEATHER ADVISORY\n${d.site_name}\n${d.scheduled_date}\nConditions may change: ${d.primary_concerns.join(', ')}\n${d.reason}\nMonitor conditions & be ready to stop. Reply OK to confirm.`;
      
    case 'RESCHEDULE':
      return `🔄 SCHEDULE CHANGE\n${d.site_name}\n${d.activity_type} moved from ${d.scheduled_date} to ${d.recommended_new_date}\nReason: ${d.reason}\nReply OK to confirm or CALL to discuss.`;
      
    case 'STOP_WORK_HEAT':
      return `🔴 HEAT ALERT - ${d.site_name}\nHeat index forecast: DANGEROUS\n${d.osha_heat_details}\nActivity: ${d.activity_type}\nDate: ${d.scheduled_date}\n${d.reason}\nReply OK to confirm. OSHA compliance required.`;
      
    case 'STOP_WORK_SEVERE':
      return `🚨 SEVERE WEATHER - STOP WORK\n${d.site_name}\n${d.primary_concerns.join(', ')}\n${d.reason}\nDO NOT send crews. Reply OK to confirm.`;
      
    default:
      return `Weather update for ${d.site_name}: ${d.reason}. Reply OK to confirm.`;
  }
}

function composeWorkerSMS(d) {
  // Shorter messages for individual workers
  switch (d.decision) {
    case 'RESCHEDULE':
      return `Schedule change: ${d.activity_type} at ${d.site_name} moved to ${d.recommended_new_date}. Contact your crew lead for details.`;
      
    case 'STOP_WORK_HEAT':
      return `⚠️ HEAT ALERT: ${d.site_name} on ${d.scheduled_date}. ${d.osha_heat_details} Contact your crew lead.`;
      
    case 'STOP_WORK_SEVERE':
      return `🚨 SEVERE WEATHER: Do not report to ${d.site_name} on ${d.scheduled_date}. Stay safe. Contact crew lead for updates.`;
      
    default:
      return null;
  }
}

function composeOfficeEmail(d) {
  return {
    subject: `[Weather Agent] ${d.decision}: ${d.site_name} - ${d.scheduled_date}`,
    body: `Weather Rescheduling Decision\n\nSite: ${d.site_name}\nActivity: ${d.activity_type}\nOriginal Date: ${d.scheduled_date}\nDecision: ${d.decision}\nNew Date: ${d.recommended_new_date || 'N/A'}\nRisk Level: ${d.risk_level}\n\nReason: ${d.reason}\n\nConcerns: ${d.primary_concerns.join(', ')}\n\nOSHA Heat Alert: ${d.osha_heat_alert ? 'YES - ' + d.osha_heat_details : 'No'}\n\nConfidence: ${(d.confidence * 100).toFixed(0)}%\n\n---\nThis decision was generated automatically by the Weather Rescheduling Agent.\nWeather data source: Visual Crossing API\nDecision engine: GPT-4.1-mini\nTimestamp: ${new Date().toISOString()}`
  };
}

function composeTeamsCard(d) {
  const colorMap = {
    'PROCEED': '00FF00',
    'MONITOR': 'FFA500',
    'RESCHEDULE': 'FF8C00',
    'STOP_WORK_HEAT': 'FF4500',
    'STOP_WORK_SEVERE': 'FF0000'
  };
  
  return {
    '@type': 'MessageCard',
    '@context': 'http://schema.org/extensions',
    'themeColor': colorMap[d.decision] || '808080',
    'summary': `Weather ${d.decision}: ${d.site_name}`,
    'sections': [{
      'activityTitle': `${d.decision === 'STOP_WORK_SEVERE' ? '🚨' : d.decision === 'STOP_WORK_HEAT' ? '🔴' : d.decision === 'RESCHEDULE' ? '🔄' : '⚠️'} Weather ${d.decision}`,
      'activitySubtitle': d.site_name,
      'facts': [
        { 'name': 'Activity', 'value': d.activity_type },
        { 'name': 'Date', 'value': d.scheduled_date },
        { 'name': 'New Date', 'value': d.recommended_new_date || 'N/A' },
        { 'name': 'Risk Level', 'value': d.risk_level.toUpperCase() },
        { 'name': 'Concerns', 'value': d.primary_concerns.join(', ') },
        { 'name': 'OSHA Heat', 'value': d.osha_heat_alert ? '⚠️ YES' : 'No' }
      ],
      'text': d.reason
    }]
  };
}

const crewLeadMsg = composeCrewLeadSMS(decision);
const workerMsg = composeWorkerSMS(decision);
const emailContent = composeOfficeEmail(decision);
const teamsCard = composeTeamsCard(decision);

return [{
  json: {
    ...decision,
    notifications: {
      crew_lead_sms: crewLeadMsg,
      worker_sms: workerMsg,
      office_email: emailContent,
      teams_card: teamsCard
    }
  }
}];

SMS Reply Confirmation Handler

Type: integration Webhook endpoint that receives incoming SMS replies from crew leads via Twilio, parses the response, updates the decision confirmation status, and triggers escalation if no confirmation is received within the timeout window.

Implementation

1
Node 1: Webhook Trigger — Path: /twilio-reply, HTTP Method: POST, Response Mode: Last Node (to return TwiML)
2
Node 2: Code Node — Parse and Process Reply (see code below)
3
Node 3: Switch Node on action — Route 'confirmed' → Update DB, Route 'rejected' → Update DB + Trigger Escalation, Route 'unclear' → Log only
4
Node 4 (confirmed path): Postgres Update (see query below)
5
Node 5 (rejected path): Escalation — Send SMS to superintendent: 'Crew lead {name} at {site} rejected the weather reschedule. Please call them at {phone}.' Log escalation in notifications table
6
Node 6: Respond to Webhook — Return the TwiML XML as the HTTP response, Content-Type: text/xml, Body: result.reply_twiml
n8n Webhook Node: Twilio SMS Reply Handler — Webhook URL: https://<n8n-instance>/webhook/twilio-reply | Method: POST | Authentication: None (Twilio sends form-encoded POST) | Response: TwiML XML
javascript
const body = $input.first().json.body || $input.first().json;
const fromPhone = (body.From || '').replace(/[^0-9+]/g, '');
const messageBody = (body.Body || '').trim().toUpperCase();
const messageSid = body.MessageSid || '';

// Fuzzy match confirmation responses
const confirmPatterns = ['OK', 'YES', 'CONFIRM', 'CONFIRMED', 'GOT IT', 'ROGER', 'COPY', 'ACK', '10-4', 'SOUNDS GOOD', 'WILL DO', 'K', 'YEP', 'YUP', 'SURE'];
const rejectPatterns = ['NO', 'REJECT', 'CALL', 'CALL ME', 'DISAGREE', 'PROBLEM', 'ISSUE', 'WRONG', 'CANT', "CAN'T"];

const isConfirm = confirmPatterns.some(p => messageBody.includes(p));
const isReject = rejectPatterns.some(p => messageBody.includes(p));

let action = 'unknown';
let replyText = '';

if (isConfirm) {
  action = 'confirmed';
  replyText = 'Schedule change confirmed. Stay safe out there! 👷';
} else if (isReject) {
  action = 'rejected';
  replyText = 'Got it. Escalating to your superintendent now. They will call you shortly.';
} else {
  action = 'unclear';
  replyText = 'Sorry, I didn\'t understand. Reply OK to confirm the schedule change, or CALL to speak with your superintendent.';
}

// Return data for downstream processing
const result = {
  from_phone: fromPhone,
  message_body: messageBody,
  message_sid: messageSid,
  action: action,
  reply_twiml: `<?xml version="1.0" encoding="UTF-8"?><Response><Message>${replyText}</Message></Response>`
};

return [{ json: result }];
Node 4 (confirmed path): Postgres Update
sql
-- reschedule_decisions confirmation

UPDATE reschedule_decisions
SET confirmed_by_lead = true, confirmed_at = NOW()
WHERE id = (
  SELECT rd.id FROM reschedule_decisions rd
  JOIN notifications n ON n.reschedule_decision_id = rd.id
  WHERE n.recipient_phone = $1
  AND rd.confirmed_by_lead = false
  ORDER BY rd.decided_at DESC LIMIT 1
);

Escalation Timer Workflow (separate workflow)

  • Trigger: Cron every 15 minutes
  • 30-minute query: Find unconfirmed decisions between 30–31 minutes old → Send follow-up SMS to crew lead
  • 60-minute query: Find unconfirmed decisions between 60–61 minutes old → Trigger voice call to superintendent via Twilio
Escalation Timer — 30-minute follow-up SMS query
sql
SELECT * FROM reschedule_decisions
  WHERE confirmed_by_lead = false
  AND decided_at < NOW() - INTERVAL '30 minutes'
  AND decided_at > NOW() - INTERVAL '31 minutes';
Escalation Timer — 60-minute superintendent voice call query
sql
SELECT * FROM reschedule_decisions
  WHERE confirmed_by_lead = false
  AND decided_at < NOW() - INTERVAL '60 minutes'
  AND decided_at > NOW() - INTERVAL '61 minutes';
Twilio Voice TwiML — Superintendent escalation call script
xml
<Response><Say voice="alice">Attention: Weather alert for site {site_name}. Crew lead {lead_name} has not confirmed the schedule change made {minutes} minutes ago. The original activity was {activity_type} scheduled for {date}. Please contact the crew lead at {phone} or call the office.</Say></Response>

Weather Delay Documentation Generator

Type: workflow Automated workflow that compiles weather data, AI decisions, notifications, and confirmations into a formal weather delay report suitable for AIA contract delay claims and OSHA compliance records. Runs daily at 6 PM and generates PDF-style documentation.

Implementation

1
Schedule Trigger — Daily at 18:00
2
Postgres Query — Get Today's Decisions
3
IF — Any decisions today? Condition: items count > 0
4
Code Node — Generate Report HTML
5
Send Email via SendGrid — To: office@clientdomain.com, superintendent@clientdomain.com | Subject: Weather Delay Report - {date} | Attachment: HTML report | Also store in SharePoint/Google Drive if integrated
6
Postgres — Archive report reference
Node 2: Postgres Query — Get Today's Decisions
sql
SELECT rd.*, se.activity_type, se.scheduled_date,
  js.site_name, js.address,
  wc.temperature_f, wc.wind_speed_mph, wc.precip_probability,
  wc.conditions, wc.raw_api_response,
  c.crew_name, c.lead_name,
  n.delivery_status, n.sent_at
FROM reschedule_decisions rd
JOIN schedule_entries se ON rd.schedule_entry_id = se.id
JOIN job_sites js ON se.job_site_id = js.id
JOIN weather_checks wc ON rd.weather_check_id = wc.id
JOIN crews c ON se.crew_id = c.id
LEFT JOIN notifications n ON n.reschedule_decision_id = rd.id
WHERE rd.decided_at::date = CURRENT_DATE
AND rd.decision IN ('RESCHEDULE', 'STOP_WORK_HEAT', 'STOP_WORK_SEVERE')
ORDER BY rd.decided_at;
Node 4: Code Node — Generate Report HTML
javascript
const decisions = $input.all();
const today = new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const clientName = $env.CLIENT_NAME || 'Client';

let html = `
<html><head><style>
  body { font-family: Arial, sans-serif; margin: 40px; color: #333; }
  h1 { color: #1a5276; border-bottom: 2px solid #1a5276; padding-bottom: 10px; }
  h2 { color: #2c3e50; margin-top: 30px; }
  table { border-collapse: collapse; width: 100%; margin: 15px 0; }
  th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
  th { background-color: #1a5276; color: white; }
  .reschedule { background-color: #fff3cd; }
  .heat { background-color: #f8d7da; }
  .severe { background-color: #f5c6cb; }
  .footer { margin-top: 40px; font-size: 0.85em; color: #666; border-top: 1px solid #ccc; padding-top: 10px; }
</style></head><body>
<h1>Weather Delay Report</h1>
<p><strong>Company:</strong> ${clientName}</p>
<p><strong>Date:</strong> ${today}</p>
<p><strong>Total Weather Decisions:</strong> ${decisions.length}</p>
<p><strong>Report Generated:</strong> ${new Date().toISOString()}</p>

<h2>Delay Summary</h2>
<table>
  <tr><th>#</th><th>Site</th><th>Activity</th><th>Decision</th><th>Original Date</th><th>New Date</th><th>Crew</th><th>Confirmed</th></tr>
`;

decisions.forEach((d, i) => {
  const rowClass = d.json.decision === 'STOP_WORK_SEVERE' ? 'severe' : d.json.decision === 'STOP_WORK_HEAT' ? 'heat' : 'reschedule';
  html += `<tr class="${rowClass}">
    <td>${i+1}</td><td>${d.json.site_name}</td><td>${d.json.activity_type}</td>
    <td>${d.json.decision}</td><td>${d.json.original_date}</td><td>${d.json.new_date || 'TBD'}</td>
    <td>${d.json.crew_name} (${d.json.lead_name})</td><td>${d.json.confirmed_by_lead ? 'Yes' : 'Pending'}</td>
  </tr>`;
});

html += `</table>\n<h2>Weather Conditions Detail</h2>`;

decisions.forEach((d, i) => {
  html += `
  <h3>${i+1}. ${d.json.site_name} — ${d.json.activity_type}</h3>
  <p><strong>Location:</strong> ${d.json.address}</p>
  <p><strong>Conditions:</strong> ${d.json.conditions}</p>
  <p><strong>Temperature:</strong> ${d.json.temperature_f}°F | <strong>Wind:</strong> ${d.json.wind_speed_mph} mph | <strong>Precip Probability:</strong> ${(d.json.precip_probability * 100).toFixed(0)}%</p>
  <p><strong>AI Assessment:</strong> ${d.json.reason}</p>
  <p><strong>Notification Sent:</strong> ${d.json.sent_at || 'N/A'} | <strong>Delivery Status:</strong> ${d.json.delivery_status || 'N/A'}</p>
  `;
});

html += `
<div class="footer">
  <p>This report was automatically generated by the Weather Rescheduling Agent. Weather data sourced from Visual Crossing API (forecast data, not observations). Decisions made by GPT-4.1-mini AI model with human confirmation required. This document is intended to support weather delay claims under AIA A201-2017 §8.3.1 and ConsensusDocs 200 §6.3.</p>
  <p>Retain this document for the project duration plus applicable retention period (typically 6-10 years).</p>
</div>
</body></html>`;

return [{ json: { html_report: html, filename: `weather-delay-report-${new Date().toISOString().split('T')[0]}.html` } }];
Node 6: Postgres — Archive report reference
sql
INSERT INTO delay_reports (report_date, report_html, decision_count) VALUES (...)

Intelligent Reschedule Date Finder

Type: skill Analyzes multi-day weather forecasts to find the optimal rescheduling date for postponed activities. Considers weather windows, activity-specific requirements (e.g., concrete needs 2 consecutive dry days), crew availability, and tries to minimize schedule disruption.

Implementation

n8n Code Node: Intelligent Reschedule Date Finder
javascript
// n8n Code Node: Intelligent Reschedule Date Finder
// Input: activity_type, job_site coordinates, current thresholds, next 7 days forecast
// Output: recommended_date with confidence score and reasoning

const activity = $input.first().json;
const thresholds = $('Get Thresholds').first().json;
const forecast = $('Get Extended Forecast').first().json; // Visual Crossing 7-day forecast

// Activity-specific requirements
const activityRequirements = {
  'concrete_pour': { consecutive_dry_days: 2, min_temp_window_hours: 8, notes: 'Needs dry curing period after pour' },
  'exterior_painting': { consecutive_dry_days: 1, min_temp_window_hours: 6, notes: 'Paint needs dry time; humidity < 85%' },
  'roofing': { consecutive_dry_days: 1, min_temp_window_hours: 4, notes: 'Roof must be dry for safety' },
  'grading_excavation': { consecutive_dry_days: 1, min_temp_window_hours: 6, notes: 'Soil must not be saturated' },
  'steel_erection': { consecutive_dry_days: 0, min_temp_window_hours: 4, notes: 'Primarily wind-dependent' },
  'crane_operations': { consecutive_dry_days: 0, min_temp_window_hours: 4, notes: 'Strictly wind-limited' },
  'framing': { consecutive_dry_days: 0, min_temp_window_hours: 6, notes: 'Can work in light conditions' },
  'landscaping': { consecutive_dry_days: 0, min_temp_window_hours: 4, notes: 'Moderate tolerance' },
  'general_outdoor': { consecutive_dry_days: 0, min_temp_window_hours: 6, notes: 'Default requirements' }
};

const requirements = activityRequirements[activity.activity_type] || activityRequirements['general_outdoor'];

function evaluateDay(dayForecast) {
  const hours = dayForecast.hours || [];
  const workHours = hours.filter(h => {
    const hour = parseInt(h.datetime.split(':')[0]);
    return hour >= 7 && hour <= 17; // 7 AM to 5 PM work window
  });
  
  if (workHours.length === 0) return { suitable: false, score: 0, reason: 'No forecast data for work hours' };
  
  const maxWind = Math.max(...workHours.map(h => h.windspeed || 0));
  const maxPrecipProb = Math.max(...workHours.map(h => (h.precipprob || 0) / 100));
  const maxTemp = Math.max(...workHours.map(h => h.temp || 70));
  const minTemp = Math.min(...workHours.map(h => h.temp || 70));
  const maxGust = Math.max(...workHours.map(h => h.windgust || h.windspeed || 0));
  const severeRisk = Math.max(...workHours.map(h => h.severerisk || 0));
  const avgHumidity = workHours.reduce((s, h) => s + (h.humidity || 50), 0) / workHours.length;
  
  // Calculate heat index
  let HI = 0.5 * (maxTemp + 61.0 + ((maxTemp - 68.0) * 1.2) + (avgHumidity * 0.094));
  if (HI >= 80) {
    HI = -42.379 + 2.04901523*maxTemp + 10.14333127*avgHumidity
      - 0.22475541*maxTemp*avgHumidity - 0.00683783*maxTemp*maxTemp
      - 0.05481717*avgHumidity*avgHumidity + 0.00122874*maxTemp*maxTemp*avgHumidity
      + 0.00085282*maxTemp*avgHumidity*avgHumidity - 0.00000199*maxTemp*maxTemp*avgHumidity*avgHumidity;
  }
  
  const issues = [];
  let score = 100;
  
  if (maxWind > thresholds.max_wind_mph) { issues.push(`Wind ${maxWind.toFixed(0)} mph exceeds ${thresholds.max_wind_mph} mph limit`); score -= 30; }
  if (maxPrecipProb > thresholds.max_precip_probability) { issues.push(`${(maxPrecipProb*100).toFixed(0)}% precip probability exceeds ${(thresholds.max_precip_probability*100).toFixed(0)}% limit`); score -= 30; }
  if (maxTemp > thresholds.max_temp_f) { issues.push(`High temp ${maxTemp.toFixed(0)}°F exceeds ${thresholds.max_temp_f}°F limit`); score -= 25; }
  if (minTemp < thresholds.min_temp_f) { issues.push(`Low temp ${minTemp.toFixed(0)}°F below ${thresholds.min_temp_f}°F minimum`); score -= 25; }
  if (HI > thresholds.max_heat_index_f) { issues.push(`Heat index ${HI.toFixed(0)}°F exceeds ${thresholds.max_heat_index_f}°F OSHA limit`); score -= 35; }
  if (severeRisk > 30) { issues.push(`Severe weather risk: ${severeRisk}%`); score -= 40; }
  
  // Bonus points for ideal conditions
  if (maxPrecipProb < 0.10) score += 5;
  if (maxWind < thresholds.max_wind_mph * 0.5) score += 5;
  if (maxTemp > 55 && maxTemp < 85) score += 5; // Comfortable range
  
  score = Math.max(0, Math.min(100, score));
  
  return {
    date: dayForecast.datetime,
    suitable: issues.length === 0,
    score: score,
    issues: issues,
    conditions: {
      max_temp: maxTemp,
      min_temp: minTemp,
      heat_index: Math.round(HI),
      max_wind: maxWind,
      max_gust: maxGust,
      precip_prob: maxPrecipProb,
      conditions: dayForecast.conditions || 'Unknown'
    }
  };
}

// Evaluate each day in the forecast
const days = forecast.days || [];
const evaluations = days.map(evaluateDay);

// Check consecutive dry day requirements
if (requirements.consecutive_dry_days > 1) {
  for (let i = 0; i < evaluations.length; i++) {
    if (evaluations[i].suitable) {
      let consecutiveDry = 1;
      for (let j = i + 1; j < Math.min(i + requirements.consecutive_dry_days, evaluations.length); j++) {
        if (evaluations[j].conditions.precip_prob < 0.30) consecutiveDry++;
      }
      if (consecutiveDry < requirements.consecutive_dry_days) {
        evaluations[i].suitable = false;
        evaluations[i].issues.push(`Needs ${requirements.consecutive_dry_days} consecutive dry days but only found ${consecutiveDry}`);
        evaluations[i].score -= 20;
      }
    }
  }
}

// Find best day (skip today and original date)
const candidates = evaluations
  .filter(e => e.date !== activity.scheduled_date)
  .sort((a, b) => b.score - a.score);

const bestDay = candidates[0];
const isIdeal = bestDay && bestDay.suitable;

return [{
  json: {
    recommended_date: bestDay ? bestDay.date : null,
    confidence: bestDay ? bestDay.score / 100 : 0,
    is_ideal: isIdeal,
    reason: bestDay ? 
      (isIdeal ? `${bestDay.date} has clear conditions: ${bestDay.conditions.conditions}, high ${bestDay.conditions.max_temp}°F, wind ${bestDay.conditions.max_wind} mph.` :
       `${bestDay.date} is the best available option (score: ${bestDay.score}/100) but has concerns: ${bestDay.issues.join('; ')}.`) :
      'No suitable day found in the 7-day forecast. Manual scheduling required.',
    all_evaluations: evaluations,
    activity_requirements: requirements
  }
}];

Threshold-Based Fallback Decision Engine

Type: skill A non-AI fallback decision engine that uses simple threshold comparison when the OpenAI API is unavailable. Ensures weather safety alerts continue even during API outages. This is a critical safety feature.

Implementation:

n8n Code Node: Threshold Fallback Decision Engine — used on the error handler path when the OpenAI API call fails
javascript
// n8n Code Node: Threshold Fallback Decision Engine
// Used when OpenAI API call fails (error handler path)
// Input: weather assessment + thresholds
// Output: decision object matching the AI decision schema

const assessment = $input.first().json;
const w = assessment.weather;

// Get thresholds for this activity type
const thresholdsAll = $('Get Thresholds').all();
const t = thresholdsAll.find(th => th.json.activity_type === assessment.activity_type)?.json
  || thresholdsAll.find(th => th.json.activity_type === 'general_outdoor')?.json;

if (!t) {
  return [{ json: { ...assessment, decision: 'MONITOR', reason: 'Unable to evaluate: no thresholds found. AI API unavailable. Please check weather manually.', risk_level: 'medium', fallback: true } }];
}

const concerns = [];
let decision = 'PROCEED';
let riskLevel = 'low';
let oshaHeatAlert = false;
let oshaDetails = null;

// Check severe weather first (highest priority)
if (w.severe_risk_pct > 50) {
  decision = 'STOP_WORK_SEVERE';
  riskLevel = 'critical';
  concerns.push(`Severe weather risk at ${w.severe_risk_pct}%`);
}

// Check lightning
if (w.conditions_summary && (w.conditions_summary.toLowerCase().includes('thunder') || w.conditions_summary.toLowerCase().includes('lightning'))) {
  if (t.lightning_risk_block) {
    decision = 'STOP_WORK_SEVERE';
    riskLevel = 'critical';
    concerns.push('Lightning/thunderstorm risk detected');
  }
}

// Check OSHA heat
if (w.max_heat_index_f >= 105) {
  decision = 'STOP_WORK_HEAT';
  riskLevel = 'critical';
  oshaHeatAlert = true;
  oshaDetails = 'CEASE ALL OUTDOOR WORK. Heat index 105°F+. Extreme danger of heat stroke.';
  concerns.push(`Extreme heat index: ${w.max_heat_index_f}°F`);
} else if (w.max_heat_index_f >= 90) {
  decision = decision === 'PROCEED' ? 'STOP_WORK_HEAT' : decision;
  riskLevel = riskLevel === 'low' ? 'high' : riskLevel;
  oshaHeatAlert = true;
  oshaDetails = 'Mandatory: Water every 20 min, shade access, buddy system, pre-shift safety meeting.';
  concerns.push(`High heat index: ${w.max_heat_index_f}°F`);
} else if (w.max_heat_index_f >= 80) {
  if (decision === 'PROCEED') decision = 'MONITOR';
  oshaHeatAlert = true;
  oshaDetails = 'Ensure water and shade available. Monitor workers for heat illness symptoms.';
  concerns.push(`Elevated heat index: ${w.max_heat_index_f}°F`);
}

// Check precipitation
if (w.max_precip_probability > t.max_precip_probability) {
  if (decision === 'PROCEED' || decision === 'MONITOR') decision = 'RESCHEDULE';
  if (riskLevel === 'low') riskLevel = 'medium';
  concerns.push(`Precipitation ${(w.max_precip_probability * 100).toFixed(0)}% exceeds ${(t.max_precip_probability * 100).toFixed(0)}% limit`);
} else if (w.max_precip_probability > t.max_precip_probability * 0.8) {
  if (decision === 'PROCEED') decision = 'MONITOR';
  concerns.push(`Precipitation ${(w.max_precip_probability * 100).toFixed(0)}% approaching ${(t.max_precip_probability * 100).toFixed(0)}% limit`);
}

// Check wind
if (w.max_wind_mph > t.max_wind_mph) {
  if (decision === 'PROCEED' || decision === 'MONITOR') decision = 'RESCHEDULE';
  if (riskLevel === 'low') riskLevel = 'medium';
  concerns.push(`Wind ${w.max_wind_mph} mph exceeds ${t.max_wind_mph} mph limit`);
}

// Check temperature
if (w.min_temp_f < t.min_temp_f) {
  if (decision === 'PROCEED' || decision === 'MONITOR') decision = 'RESCHEDULE';
  concerns.push(`Low temp ${w.min_temp_f}°F below ${t.min_temp_f}°F minimum`);
}
if (w.max_temp_f > t.max_temp_f) {
  if (decision === 'PROCEED' || decision === 'MONITOR') decision = 'RESCHEDULE';
  concerns.push(`High temp ${w.max_temp_f}°F exceeds ${t.max_temp_f}°F limit`);
}

// Check snow
if (w.total_snow_inches > (t.max_snow_inches || 1.0)) {
  if (decision === 'PROCEED' || decision === 'MONITOR') decision = 'RESCHEDULE';
  concerns.push(`Snow ${w.total_snow_inches}" exceeds ${t.max_snow_inches}" limit`);
}

const reason = concerns.length > 0 
  ? `[AUTO-FALLBACK] Weather concerns for ${assessment.activity_type} at ${assessment.site_name}: ${concerns.join('. ')}.`
  : `[AUTO-FALLBACK] Weather conditions are within acceptable thresholds for ${assessment.activity_type}.`;

return [{
  json: {
    schedule_entry_id: assessment.schedule_entry_id,
    site_name: assessment.site_name,
    activity_type: assessment.activity_type,
    decision: decision,
    risk_level: riskLevel,
    primary_concerns: concerns,
    reason: reason,
    recommended_new_date: null, // Fallback cannot evaluate future days without AI
    osha_heat_alert: oshaHeatAlert,
    osha_heat_details: oshaDetails,
    confidence: 0.7, // Lower confidence for rule-based decisions
    fallback: true,
    ai_unavailable: true
  }
}];

Testing & Validation

  • WEATHER POLLING TEST: Manually trigger the weather polling workflow for all active job sites. Verify that weather_checks table receives new rows for each site with non-null temperature, wind, and precipitation values. Expected: one row per site per forecast day (3 days × N sites).
  • API ERROR HANDLING TEST: Temporarily set an invalid Visual Crossing API key. Trigger the weather polling workflow. Verify that the error handler workflow fires, the MSP receives an alert notification, and no crash occurs. Restore the valid key and confirm normal operation resumes.
  • AI DECISION - CLEAR WEATHER: Insert a schedule entry for a roofing activity on a day with forecast showing clear skies, 72°F, 5 mph wind, 5% precip probability. Run the AI decision workflow. Verify the decision is 'PROCEED' with risk_level 'low'.
  • AI DECISION - HEAVY RAIN: Insert a schedule entry for exterior painting on a day with forecast showing 85% precipitation probability, rain expected. Run the AI decision workflow. Verify the decision is 'RESCHEDULE' with specific concerns mentioning precipitation exceeding the 20% threshold for exterior painting.
  • AI DECISION - HIGH WIND: Insert a schedule entry for crane operations on a day with forecast showing 35 mph sustained winds. Run the AI decision workflow. Verify the decision is 'RESCHEDULE' with concerns about wind exceeding the 20 mph crane threshold.
  • AI DECISION - OSHA HEAT: Insert a schedule entry for concrete pour on a day with forecast showing 98°F temperature and 65% humidity (heat index ~115°F). Verify the decision is 'STOP_WORK_HEAT' with osha_heat_alert=true and specific heat precautions in osha_heat_details.
  • AI DECISION - LIGHTNING: Insert a schedule entry for steel erection on a day with thunderstorm forecast. Verify the decision is 'STOP_WORK_SEVERE' regardless of temperature or wind conditions.
  • FALLBACK ENGINE TEST: Disable the OpenAI API credential temporarily. Run the AI decision workflow with a rainy-day scenario. Verify the threshold-based fallback engine produces a 'RESCHEDULE' decision and the notification includes '[AUTO-FALLBACK]' prefix. Re-enable OpenAI and confirm AI decisions resume.
  • SMS NOTIFICATION DELIVERY: Trigger a RESCHEDULE decision for a crew lead with a known test phone number. Verify the SMS is received on the phone within 30 seconds. Confirm the message includes site name, activity type, original date, new date, and reply instructions.
  • SMS CONFIRMATION FLOW: After receiving a reschedule SMS on the test phone, reply 'OK'. Verify that (a) an auto-reply 'Schedule change confirmed' is received, (b) the reschedule_decisions record is updated with confirmed_by_lead=true, and (c) the confirmation timestamp is logged.
  • SMS REJECTION FLOW: After receiving a reschedule SMS, reply 'CALL'. Verify that (a) an auto-reply about escalation is received, (b) the superintendent receives an SMS about the rejection, and (c) the escalation is logged in the notifications table.
  • ESCALATION TIMER TEST: Trigger a RESCHEDULE decision and do NOT reply to the SMS. After 30 minutes, verify a follow-up SMS is sent. After 60 minutes, verify a voice call is placed to the superintendent phone number via Twilio.
  • TEAMS NOTIFICATION TEST: Trigger a RESCHEDULE decision and verify that an adaptive card message appears in the designated Microsoft Teams channel within 60 seconds, showing site name, activity, decision, risk level, and weather concerns.
  • PM PLATFORM SYNC TEST (Procore): If integrated with Procore, trigger a RESCHEDULE decision and verify that the corresponding schedule task in Procore is updated with the new date. Also verify a daily log entry is created with weather delay checkbox marked and conditions documented.
  • PM PLATFORM SYNC TEST (Jobber/Buildertrend): If integrated, verify the corresponding job/schedule entry is updated in the client's PM platform after a reschedule decision.
  • DAILY DELAY REPORT TEST: Trigger 2-3 reschedule decisions during the day, then manually trigger the daily documentation workflow. Verify an HTML report is generated with all decisions listed, weather conditions documented, and notification status shown. Verify the email is delivered to the configured office recipients.
  • MULTI-SITE CONCURRENT TEST: Set up 5+ active job sites with varying weather forecasts. Run the full pipeline and verify the agent correctly processes all sites in a single batch, makes independent decisions per site per activity, and sends appropriate notifications to the correct crew leads.
  • HEAT INDEX CALCULATION ACCURACY: Compare the agent's calculated heat index against the NWS heat index chart for 5 temperature/humidity combinations: (80°F/40%), (90°F/60%), (95°F/70%), (100°F/50%), (105°F/65%). Verify calculated values match NWS values within ±2°F.
  • END-TO-END TIMING TEST: Measure the complete pipeline duration from cron trigger to final SMS delivery for a batch of 10 job sites. Target: under 5 minutes total. Identify and optimize any bottlenecks (typically the sequential API calls to Visual Crossing and OpenAI).
  • DATABASE AUDIT TRAIL TEST: After running the system for one full test day, query all tables and verify: (a) every weather check has a corresponding site, (b) every reschedule decision references a weather check and schedule entry, (c) every notification references a decision, (d) timestamps are consistent and sequential.

Client Handoff

The client handoff should be conducted as a 2-hour on-site or video session with the office manager, superintendent, and at least 2 crew leads present. Cover these topics in order:

1
System Overview (15 min): Explain what the agent does, when it runs (every 6 hours), and the decision types (PROCEED, MONITOR, RESCHEDULE, STOP_WORK_HEAT, STOP_WORK_SEVERE). Show the flow diagram from weather check to SMS.
2
Threshold Review (20 min): Walk through the weather thresholds for each activity type. Confirm with the superintendent that each threshold is appropriate. Show how to request threshold changes through the MSP (the client should NOT change thresholds directly).
3
Crew Lead Training (20 min): Train crew leads on: (a) what the SMS notifications look like for each decision type, (b) how to reply OK to confirm, (c) how to reply CALL to request a callback, (d) what happens if they don't reply (escalation), (e) that OSHA heat alerts require specific actions.
4
Office Manager Training (20 min): Show the office manager: (a) the daily delay report email and what each section means, (b) how to use the report for AIA delay claims, (c) how to check the Teams channel for real-time alerts, (d) who to call at the MSP if something seems wrong.
5
New Job Site / Crew Onboarding (15 min): Explain the process for adding new job sites and crews — the client emails or calls the MSP with site address, crew lead name/phone, and activity types. MSP adds them within 1 business day.
6
Edge Cases and Overrides (15 min): Discuss what happens when: (a) a crew lead wants to override the AI and work anyway (they can, the agent is advisory), (b) the weather changes after the agent's last check (crews should still use judgment), (c) the agent makes an incorrect call (report to MSP for threshold tuning).
7
Documentation Handoff: Leave behind a 1-page Quick Reference Card for crew leads (laminated for job site use) with: (a) what each notification type means, (b) how to reply, (c) MSP support phone number. Leave a 3-page Admin Guide for the office manager with: (a) threshold reference table, (b) daily report interpretation, (c) how to request changes, (d) emergency contacts.
8
Success Criteria Review: Review the agreed success metrics: (a) 100% of reschedule decisions are delivered via SMS within 5 minutes, (b) 90%+ crew lead confirmation rate within 30 minutes, (c) zero instances of crews sent to sites during unsafe weather, (d) all weather delays documented for contract compliance. Schedule a 30-day follow-up review to assess these metrics.

Maintenance

Weekly MSP Tasks (15 min/week)

  • Review n8n execution logs for any failed workflows in the past 7 days
  • Check the unconfirmed decisions count — investigate any pattern of crew leads not responding
  • Verify weather API call counts are within expected range (indicates the cron is running)
  • Review OpenAI API costs in the dashboard (should be $2-$10/month; investigate if higher)

Monthly MSP Tasks (1 hour/month)

  • Review all RESCHEDULE decisions for the month with the client superintendent
  • Identify any false positives (unnecessary reschedules) or false negatives (missed weather events) and adjust thresholds
  • Update weather thresholds if the superintendent requests changes based on field experience
  • Verify Twilio SMS delivery rates (should be >98%); investigate any delivery failures
  • Check for n8n platform updates and apply during a maintenance window (n8n Cloud auto-updates; self-hosted requires manual update)
  • Review and prune weather_checks table if exceeding 1GB (archive records older than 6 months to cold storage)
  • Confirm database backups are running successfully with 30-day retention

Quarterly MSP Tasks (2 hours/quarter)

  • Conduct a Quarterly Business Review (QBR) with the client reviewing: total reschedule decisions, estimated cost savings (crews × avg mobilization cost × reschedules), OSHA compliance events, and delay claim documentation generated
  • Update the AI system prompt if OpenAI releases a new recommended model version (test with historical data before switching)
  • Review API pricing changes from Visual Crossing, OpenAI, and Twilio — adjust client billing if costs have changed
  • Add any new job sites or crews that have been requested
  • Test the escalation path end-to-end (trigger a test alert and verify SMS → follow-up → voice call works)

Seasonal Adjustments

  • Spring/Summer: Tighten heat index thresholds, increase polling frequency to every 4 hours during heat waves, ensure OSHA heat compliance module is active
  • Fall/Winter: Add freeze/frost monitoring, adjust min temperature thresholds downward for cold-weather concrete, enable snow accumulation checks
  • Hurricane/severe weather season: Enable severe weather alert monitoring with Tomorrow.io if using that API; increase polling to every 2 hours when tropical systems are within 500 miles

SLA Considerations

  • Target 99.5% uptime for the agent (allows ~3.6 hours/month downtime)
  • Weather poll failures should be detected within 15 minutes (via the error handler workflow) and resolved within 2 hours
  • Threshold changes requested by the client should be implemented within 1 business day
  • New job site additions should be live within 1 business day
  • Emergency MSP support (agent completely down) should have a 1-hour response SLA during business hours

Escalation Path

1
n8n execution failure → MSP on-call tech receives automated alert email/SMS → investigate within 2 hours
2
Weather API outage → Fallback engine handles decisions → MSP tech switches to backup API (OpenWeatherMap) within 4 hours
3
OpenAI API outage → Threshold fallback engine activates automatically → no manual intervention needed, but MSP should monitor for prolonged outages
4
Twilio SMS failure → MSP receives alert → switch to backup notification (Teams + email only) → investigate Twilio status page
5
Client reports missed weather event → MSP reviews decision logs → adjusts thresholds → documents in change log

Alternatives

Microsoft Power Automate + Copilot Studio Approach

Replace n8n with Microsoft Power Automate for workflow orchestration and Copilot Studio for the AI decision agent. Uses the Microsoft 365 ecosystem end-to-end: Power Automate runs the weather polling and notification workflows, Copilot Studio hosts the AI decision logic, Teams delivers notifications, and SharePoint stores documentation.

CrewAI Multi-Agent Framework

Replace n8n + GPT-4.1-mini with a CrewAI-based multi-agent system running on a dedicated VM. Deploy three specialized agents: a Weather Analyst agent that interprets forecast data, a Schedule Optimizer agent that finds the best reschedule dates considering dependencies, and a Communication Agent that crafts context-appropriate notifications. CrewAI orchestrates the agents in a sequential workflow.

Zapier + ChatGPT Integration (Simplified)

Use Zapier as the sole automation platform with its built-in ChatGPT integration. Create Zaps that poll weather APIs, send data to ChatGPT for evaluation, and trigger SMS/email notifications. This is the simplest possible implementation with the lowest technical barrier.

StruxHub or ALICE Technologies (Purpose-Built Platform)

Instead of building a custom agent, subscribe to StruxHub or ALICE Technologies which offer AI-powered construction scheduling with built-in weather integration. These platforms natively analyze weather conditions and optimize crew schedules.

Tomorrow.io Webhook-Driven Architecture

Replace the cron-based polling model with Tomorrow.io's built-in alerting rules engine. Configure weather trigger conditions (e.g., wind > 25 mph at site location) in Tomorrow.io's dashboard, and have Tomorrow.io push webhook notifications to n8n only when thresholds are exceeded. This inverts the architecture from pull (polling) to push (event-driven).

Want early access to the full toolkit?