
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
$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
$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
$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
$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
$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.
Tomorrow.io Weather API (Alternative Primary)
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)
$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
$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
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)
$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
$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.
export VISUAL_CROSSING_API_KEY='your-key-here'export OPENAI_API_KEY='sk-your-key-here'export TWILIO_ACCOUNT_SID='your-sid'
export TWILIO_AUTH_TOKEN='your-token'
export TWILIO_PHONE_NUMBER='+1XXXXXXXXXX'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 30Store 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.
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);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.
CLIENT_NAME=<client company name>
DEFAULT_TIMEZONE=America/New_York
ALERT_ESCALATION_PHONE=+1XXXXXXXXXX
FORECAST_HOURS_AHEAD=24
POLLING_INTERVAL_HOURS=6docker 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:latestIf 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.
\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;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'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 } } } }"}'curl 'https://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/1234%20Main%20St%20Denver%20CO?key=<API_KEY>&include=current' | jq '.latitude, .longitude'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.
SELECT id, site_name, latitude, longitude, address FROM job_sites WHERE is_active = truehttps://weather.visualcrossing.com/VisualCrossingWebServices/rest/services/timeline/{{$json.latitude}},{{$json.longitude}}/next3dayskey: {{$credentials.vcApiKey}}
unitGroup: us
include: hours,alerts,current
elements: datetime,temp,feelslike,humidity,precip,precipprob,preciptype,snow,windspeed,windgust,uvindex,conditions,description,severeriskINSERT INTO weather_checks (job_site_id, forecast_date, temperature_f, ...)
VALUES ($1, $2, $3, ...) RETURNING idVisual 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.
- 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.
{
'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': [...]
}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.
# [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>'
})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
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}}"
}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
# 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
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.
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');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}}];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
Option B: Launch Grafana with Docker
docker run -d --name grafana -p 3000:3000 grafana/grafana-oss:latestGrafana Alerting Rules
Daily Agent Health Check Query
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;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.
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 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');SELECT * FROM reschedule_decisions ORDER BY decided_at DESC LIMIT 5;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
// 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:
// 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
// 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
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 }];-- 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
SELECT * FROM reschedule_decisions
WHERE confirmed_by_lead = false
AND decided_at < NOW() - INTERVAL '30 minutes'
AND decided_at > NOW() - INTERVAL '31 minutes';SELECT * FROM reschedule_decisions
WHERE confirmed_by_lead = false
AND decided_at < NOW() - INTERVAL '60 minutes'
AND decided_at > NOW() - INTERVAL '61 minutes';<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
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;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` } }];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
// 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 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:
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
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?