---
name: heat-guidance-api
description: >-
  Query the Heat Stress Work/Rest Guidance Calculator REST API to turn workplace
  heat conditions (WBGT, workload, acclimatization, shift window, PPE, location)
  into occupational work/rest schedules, hydration guidance, and stop-work
  triggers across 102 national, international, and industry standards. Use when an
  agent needs authoritative heat-stress guidance, WBGT estimates from a lat/lon,
  wildfire-smoke AQI, or a country's applicable heat rules.
homepage: https://heatrules.com
openapi: https://heatrules.com/openapi.json
license: decision-support only — not a substitute for a safety professional
---

# Heat Stress Work/Rest Guidance API — Skill

This document teaches an AI agent how to call the Heat Stress Work/Rest Guidance
Calculator API. It is served on the web at
`https://heatrules.com/skill.md` and pointed to from
`/llms.txt`. Machine-readable contract: `/openapi.json` (OpenAPI 3.1).

## What this API does

Given the heat conditions a worker faces, it evaluates **102 registered heat
standards** (La Isla RSH-s, ACGIH TLV, NIOSH, ISO 7243/7933, TB MED 507, and
national/sub-national rules covering 54 countries across 76 distinct
jurisdictions) and returns:

- a per-standard **work/rest schedule** (work minutes/hour, rest minutes/hour) or stop-work trigger,
- a **composite** = the *most protective* applicable schedule (never an average),
- **derived outputs**: hydration rate, acute-kidney-injury (AKI) risk tier, productivity loss, and a shift calendar,
- explicit **missing-input** disclosure for standards that can't be evaluated (it never fabricates a schedule).

It can also estimate WBGT from a latitude/longitude forecast, return
wildfire-smoke AQI, geocode a location, and read three official national
heat-alert feeds (Spain, France, Austria).

> **Safety contract.** Outputs are decision support, not a legal determination
> or a replacement for a qualified safety professional. A **measured** on-site
> WBGT is always more trustworthy than the forecast estimate. Surface
> `confidence` and `warnings` to the end user; never hide uncertainty.

## Base URL and transport

- Production base URL: `https://heatrules.com`
- All responses are JSON. CORS is open (`Access-Control-Allow-Origin: *`), so browser-based agents work.
- Most bodies include `"success": true`. Errors return `{"success": false, "errors": [ ... ]}`. Two endpoints are envelope-free: `/health` returns `{"ok": true}` and `/api/v1/geo/country` returns its fields at the top level with no `success` key.

## Rules for calling `/api/v1/*` (read this — it prevents 403/429)

The edge middleware enforces these. Static docs (`/llms.txt`, `/skill.md`,
`/openapi.json`) and `/health` are exempt, but every `/api/v1/*` call must obey:

1. **Send a real `User-Agent`** — at least 8 characters (e.g. `HeatAgent/1.0 (+contact)`). Missing or short UAs get `403 User-Agent required.`
2. **Do not identify as a crawler or verified bot.** UAs matching `googlebot`, `bingbot`, `slurp`, `duckduckbot`, `baiduspider`, `yandexbot`, `facebookexternalhit`, `twitterbot`, `pinterest`, `slack`, `discord`, `telegram`, or any Cloudflare-verified bot get `403 Crawlers not allowed on this endpoint.`
3. **Do not call from a proxy/VPN-hosting ASN.** A short list of hosting ASNs gets `403 Access denied.` on API paths only.
4. **Respect rate limits:** 20 requests / 10 s (burst) and 120 requests / 60 s (sustained), per client IP. On `429`, read the `Retry-After` header (seconds) and back off. Limits are enforced per Cloudflare datacenter and are best-effort, not a globally consistent quota — they add friction to casual abuse and are not protection against request flooding.
5. `POST` endpoints require `Content-Type: application/json` and a JSON body.

Responses carry `Cache-Control: no-store`, so do not expect a shared CDN cache
to absorb your traffic — cache client-side instead.

## Input model (shared by compare / evaluate / guidance)

Send inputs either flat or wrapped in an `input` object — both work:

```json
{ "input": { "wbgtC": 30, "workload": "heavy" } }
```

### Common fields

| Field | Type | Notes |
|---|---|---|
| `wbgtC` | number | WBGT in °C. Range −40…60. Primary heat input. |
| `wbgtF` | number | WBGT in °F (alternative to `wbgtC`). Range −40…140. |
| `japanWbgtC` | number | Japan MOE-consistent WBGT in °C. Range −40…60. Only `japan_wbgt` reads it, and it bands this instead of `wbgtC` when present — Japan's bands are calibrated against MOE's forced-ventilation WBGT, not the ISO natural-wet-bulb one. Supplied by `/api/v1/weather/wbgt`'s `japan.wbgtC` for worksites near a MOE point. |
| `japanWbgtSource` | string | Which feed produced `japanWbgtC`: `moe_measured`, `moe_estimated`, `ono_2014`, or `iso_estimate`. Reported back in the `japan_wbgt` result; `iso_estimate` means the value is *not* Japan-consistent. |
| `wbgtUnit` | `"C"` \| `"F"` | Unit hint for the response. |
| `wbgtInputMode` | `single` \| `start_peak` \| `hourly` | How WBGT is supplied. |
| `hourlyWbgt` | array | `[{ "time": "HH:MM", "valueC": 29.4 }, ...]` (or `value`/`valueF`). Used for variable schedules. |
| `workload` | `light` \| `moderate` \| `heavy` \| `very_heavy` | Metabolic intensity class. |
| `acclimatization` | `unacclimatized` \| `partial` \| `acclimatized` \| `unknown` \| `new` | Applies ±0–3 °C WBGT offset. |
| `shiftStart`, `shiftEnd` | `"HH:MM"` | 24-h local times. Drive midday-ban checks and the shift calendar. |
| `date` | `"YYYY-MM-DD"` | Required by seasonal/date-range rules (Gulf bans, Spain, France, Greece, etc.). |
| `relativeHumidity` | number | 0…100 %. Used by heat-index standards and WBGT estimation. |
| `metabolicRateW` | number | 1…1000 W. Required by ISO 7933 and Brazil NR-15; else derived from `workload`. |
| `sunExposure` / `outdoor` | boolean | Indoor vs. outdoor (solar-load) distinction. `outdoor` falls back to `outdoorSunExposed`. |
| `shade` | boolean | Shade availability (alias `shadeAvailable`). Required by the Hong Kong HKHI engine. |
| `ppe` | boolean | Heat-retaining PPE worn (alias `heatRetainingPpe`). Drives the clothing adjustment factor. |

### Advanced / physiological fields (only some standards need them)

`airTempC` (−40…60), `wetBulbC` (−40…60), `globeTempC` (−40…100),
`meanRadiantTempC` (−40…100), `windSpeedMS` (0…100), `atmPressurehPa` (800…1100),
`twlWm2` (0…600), `heatIndexF` (−40…200), `maxTempC` (−40…60),
`indoorTempC` (−40…60), `aqiPm25` (0…500), `pm25ugm3` (0…1000),
`operativeTempC` (−40…100), `restZoneWbgtC` (−40…60),
`restMetabolicRateW` (1…1000).

`operativeTempC` is the combined air-plus-radiant temperature the Czech
NV 361/2007 § 3b(1) and Slovak vyhláška 99/2016 heat provisions are written on.
It is **not** interchangeable with `globeTempC`: both texts allow `to = tg` only
where air velocity is at or below 0.2 m/s, and above that the Czech engine
labels its result a screening estimate rather than a compliance determination.
Bounded like `globeTempC` rather than `airTempC` because it rises with radiant
load.

`restZoneWbgtC` and `restMetabolicRateW` describe the **recovery zone** in
Argentina Res. SRT 30/2023 § 9.1, measured separately from the work zone. They
are variables B and A of the `ft` work/recovery equation; the regulation requires
the rest site to be cooler (`B < A`), and a failing condition returns a reason
instead of a schedule.

### Contextual flags (no range check; passed straight to the engines)

`ventilation`, `heatSource`, `outdoorSunExposed`, `restrictiveClothing`,
`highRadiantHeat`, `wildfireSmoke`, `filteredIndoorAir`, `agriculturalWork`,
`increasedRiskConditions`, `restsAtWorkSite`, `physiologicalStrainExcessive`,
`temperaturesExceededOver50PctOfDays`, `heatwaveActive` (booleans);
`clothingInsulationClo`, `employerSize`
(numbers); `workerStatus`, `industry`, `usStateCode`, `sex`, `nwsHeatAlert`
(strings); and the two
forecaster-judged national alert levels `aemetAlertLevel` (Spain) and
`meteoFranceVigilance` (France) — these are never computed from temperature, so
fetch them from `/api/v1/weather/es-heat-alert` and `/api/v1/weather/fr-vigilance`.

Out-of-range values are **rejected** (HTTP 400), never silently clamped. Omit a
field rather than passing an empty string — `""` is treated as absent.

> **Known limitation — 15 optional inputs are not plumbed through this REST API.**
> `/api/v1/standards` advertises these in some standards' `optionalInputs`, but
> the REST request normalizer drops them, so sending them has no effect on the
> result: `time`, `heatIndexC`, `geoSphereWarningLevel`, `italyRegion`,
> `worklimateRiskLevel`, `regionalOrdinanceApplies`, `greeceCriticalInfrastructure`,
> `greeceEmergencyOrderApplies`, `heatEmergencyActive`, `foodDeliveryWorker`,
> `cityContractor`, `outdoorWorker`, `impermeablePpe`, `romaniaExtremePersists2Days`,
> `romaniaTempHumidityIndexOver80`. All are *optional*, so no standard becomes
> unevaluable — but the affected engines (`austria_hitzeschutz_verordnung`,
> `italy_worklimate`, `italy_regional_heat_ordinance`, `greece_ministry_heatwave`,
> `nyc_eo17_outdoor_heat`, `romania_oug_99_2000`, `cal_osha_indoor`,
> `san_antonio_contractor_heat`, `gujarat_hap`, and the heat-index engines) will
> return their unrefined branch and say so in `recommendation.label`. Do not tell
> a user you supplied a value that the API ignored.

## Endpoints

### `GET /health`
Liveness. Returns `{"ok": true}`. No auth, no rate context.

### `GET /api/v1/standards`
Returns every registered standard with metadata. Each entry has exactly these
13 fields:

```json
{
  "success": true,
  "standards": [
    {
      "id": "la_isla_network_rshs",
      "uid": "HGC-001",
      "displayName": "La Isla Network-informed RSH-s Default Protocol",
      "authority": "La Isla Network / Adelante Initiative",
      "jurisdiction": "global",
      "countryCode": null,
      "regionCodes": [],
      "legalStatus": "industry_guidance",
      "ruleType": "schedule_engine",
      "priority": "default",
      "requiredInputs": [
        "wbgtC",
        "workload",
        "shiftStart",
        "shiftEnd"
      ],
      "optionalInputs": [
        "acclimatization",
        "ppe",
        "cafC"
      ],
      "sourceIds": [
        "la_isla_prevent_heat",
        "la_isla_rsh_peer_review"
      ]
    }
  ]
}
```

Enumerations you can rely on:

- `legalStatus` ∈ `regulation` | `technical_standard` | `guidance` | `industry_guidance` | `proposed_rule` | `historical` | `source_needs_verification`. Only `regulation` is legally binding somewhere; `historical` and `proposed_rule` are not currently in force; `source_needs_verification` means the citation has not been confirmed against a primary source — say so if you surface it.
- `ruleType` ∈ `schedule_engine` (33) | `control_overlay` (31) | `stop_work_rule` (10) | `mixed_trigger_schedule` (7) | `screening_limit` (6) | `alert_overlay` (4) | `smoke_overlay` (3).
- `priority` ∈ `default` (the LIN RSH-s protocol) | `comparison` (everything else). It is a string, not a rank number.

Use `requiredInputs` to decide what to collect, and `id` values as `standardIds`
in the calls below. There are 102 standards; fetch this once and cache it.

### `POST /api/v1/compare`
Evaluate all standards (omit `standardIds`) or a subset.

Request:
```json
{
  "input": {
    "wbgtC": 30, "workload": "heavy", "acclimatization": "acclimatized",
    "shiftStart": "08:00", "shiftEnd": "17:00", "date": "2026-04-28"
  },
  "standardIds": ["la_isla_network_rshs", "acgih_tlv_action_limit", "tb_med_507"]
}
```

Response (abridged):
```json
{
  "success": true,
  "inputSummary": {
    "wbgtInputMode": "single", "wbgtUnit": "C", "wbgtPeakHour": 14,
    "rawWbgtC": 30, "rawWbgtF": 86, "effectiveWbgtC": 30, "effectiveWbgtF": 86,
    "workload": "heavy", "acclimatization": "acclimatized",
    "clothing": "work", "clothingAdjustmentC": 0,
    "shiftStart": "08:00", "shiftEnd": "17:00", "date": "2026-04-28",
    "hourlyWbgtProvided": false
  },
  "composite": { "...": "see the two shapes below" },
  "results": [
    {
      "standardId": "la_isla_network_rshs", "uid": "HGC-001",
      "displayName": "La Isla Network-informed RSH-s Default Protocol",
      "authority": "La Isla Network / Adelante Initiative",
      "applicable": true, "hiddenReason": null, "missingInputs": [],
      "confidence": "medium", "inputDataQuality": "estimated",
      "effectiveWbgtC": 30, "workloadUsed": "heavy",
      "acclimatizationUsed": "acclimatized",
      "recommendation": {
        "workMinutesPerHour": 20, "restMinutesPerHour": 40, "stopWork": false,
        "hydrationLPerHour": 0.5, "hydrationQtPerHour": 0.75,
        "minimumControls": ["..."], "warnings": []
      },
      "explanation": "", "sourceNotes": [ { "label": "...", "url": "...", "publisher": "...", "officialStatus": "official" } ],
      "warnings": [], "legalStatus": "industry_guidance", "ruleType": "schedule_engine",
      "priority": "default", "jurisdiction": "global", "countryCode": null,
      "regionCodes": [], "sourceIds": ["..."], "parametersLastUpdated": "..."
    }
  ],
  "derivedOutputs": {
    "hydration": { "mlPerHr": 710, "cupsPerHr": 3, "mlPerBreak": 710, "cupsPerBreak": 3,
                   "qtPerHr": 0.75, "breaksPerHr": 0, "hyponatremiaCeiling": false,
                   "sourceLabel": "AAFA + TB MED 507" },
    "akiRisk": { "level": "safe", "label": "Kidney Risk: Baseline", "color": "#44B4AE", "note": "..." },
    "productivityImpact": { "sameDayPct": 9, "lagDayPct": 6.3 },
    "calendarBlocks": [ { "start": "08:00", "end": "09:00", "activity": "work", "label": "Work", "standardId": "all" } ],
    "scheduleType": "fixed", "scheduleStandardId": "la_isla_network_rshs",
    "hourlyWbgt": [], "scheduleSuggestion": null
  }
}
```

**`composite` has two mutually exclusive shapes. Branch on `stopWork`.**

Stop-work override (any applicable `schedule_engine`, `mixed_trigger_schedule`,
or `stop_work_rule` result demands it) — note there are **no** work/rest minutes
in this shape:
```json
{
  "stopWork": true,
  "reason": "At least one applicable standard requires stop work or prohibits continued work.",
  "triggeredBy": "ACGIH TLV / Action Limit",
  "contributingStandards": ["acgih_tlv_action_limit"]
}
```

Schedule:
```json
{
  "stopWork": false,
  "workMinutesPerHour": 15,
  "restMinutesPerHour": 45,
  "note": "Most protective applicable schedule selected. Advisory and screening standards are shown but not averaged into the composite schedule.",
  "contributingStandards": ["la_isla_network_rshs", "acgih_tlv_action_limit"],
  "advisoryStandards": ["..."]
}
```

`workMinutesPerHour` / `restMinutesPerHour` are **absent** when no
schedule-producing standard applied; `note` then says so and only
`advisoryStandards` is populated. `smoke_overlay`, `control_overlay`,
`alert_overlay`, and `screening_limit` results never contribute to the composite
schedule — they land in `advisoryStandards`. Wildfire-smoke guidance is a
separate overlay by design; never merge it into a work/rest number.

**How to read the answer:** the headline is `composite.stopWork` first, then
`composite.workMinutesPerHour` / `composite.restMinutesPerHour`. Per-standard
detail is in `results`. Standards you can't satisfy appear with
`applicable: false`, a `missingInputs` array, and a human-readable
`hiddenReason` — collect those inputs and retry if you need them. Expect roughly
half the registry to be inapplicable on a minimal input set (43 of 94 with only
WBGT/workload/shift/date supplied); that is correct behavior, not an error.

### `POST /api/v1/standards/{standardId}/evaluate`
One standard. Body is just the input (no `standardIds`). Returns `inputSummary`,
`standard`, `riskLevel`, `workRestSchedule`, and `derivedOutputs`. An unknown
`standardId` is a `400`, not a `404`.

```bash
curl -X POST "https://heatrules.com/api/v1/standards/la_isla_network_rshs/evaluate" \
  -H "Content-Type: application/json" -H "User-Agent: HeatAgent/1.0" \
  --data '{ "input": { "wbgtC": 28, "workload": "heavy", "acclimatization": "acclimatized" } }'
```

### `POST /api/v1/guidance/country?country=XX`
Same as `compare`, but `results` are filtered and ranked by relevance to the
ISO 3166-1 alpha-2 `country` code (exact-country, regional-bloc, text-hint, and
global standards like NIOSH/ISO/ACGIH remain). `composite` is recomputed from the
**filtered** `results`, so its `triggeredBy` standard always appears in the
`results` array you get back. The response adds a `countryCode` field, echoing your input
or `"unknown"`. Omit `country` to get the unfiltered list.

### `GET /api/v1/wbgt?lat=&lon=&date=&time=&shiftStart=&shiftEnd=&provider=`
Forecast-estimated WBGT for a coordinate. `lat` (−90…90) and `lon` (−180…180)
required; `date=YYYY-MM-DD`, `time=HH:MM` (an alias for `shiftStart` on this
route only), a `shiftStart`/`shiftEnd` window, and
`provider=auto|open-meteo|nws` are optional. Uses Open-Meteo (global) with an
NWS fallback for US locations.

The temperatures are **nested**, not top-level:

```json
{
  "success": true, "provider": "Open-Meteo", "sourceUrl": "...",
  "latitude": 33.45, "longitude": -112.07, "timezone": "America/Phoenix",
  "generatedAt": "2026-07-15T18:00:00.000Z",
  "confidence": "...", "method": "...", "warnings": [], "lightningRisk": null,
  "weather": { "time": "...", "airTempC": 41.2, "relativeHumidity": 18,
               "dewPointC": 12.1, "windSpeedMS": 3.4, "solarRadiation": 812,
               "directRadiation": 700, "diffuseRadiation": 112,
               "directNormalIrradiance": 880, "surfacePressurehPa": 1004, "isDay": 1 },
  "wbgt": { "wbgtC": 31.8, "wetBulbC": 19.4, "globeTempC": 52.1,
            "heatIndexF": 103, "globeMethod": "...", "wetBulbMethod": "...",
            "uncertaintyC": 0.4, "uncertaintyTerms": {...}, "nearCalm": false },
  "hourlyWbgt": [ { "time": "HH:MM", "valueC": 29.4 } ]
}
```

Feed `wbgt.wbgtC` into `compare`/`evaluate` when you have no measured WBGT — and
tell the user it is an estimate. Pass `hourlyWbgt` straight through with
`wbgtInputMode: "hourly"` for a variable-day schedule. Upstream forecast failures
return `502`.

### `GET /api/v1/weather/wbgt?lat=&lon=&date=&shiftStart=&shiftEnd=`
The same estimator behind the browser app. Identical response shape to
`/api/v1/wbgt`; the only difference is that it does not accept the `time` alias.
Prefer `/api/v1/wbgt` for agent use.

### `GET /api/v1/weather/wbgt-history?lat=&lon=&month=YYYY-MM`
Hourly WBGT for one **completed** calendar month, reconstructed from the Open-Meteo
ERA5 archive through the same Liljegren 2008 chain as the forecast routes. Returns
`{ "success": true, "provider": "Open-Meteo ERA5", "dataset": "reanalysis",
"month", "timezone", "utcOffsetSeconds", "warnings", "hours": [...] }`, where each
hour is `{ date, time, utcOffsetSeconds, valueC, tempC, relativeHumidity,
windSpeedMS, solarRadiation, nearCalm, uncertaintyC, source: "era5_estimate" }`.

Four things to know before using it:
- **`date`/`time` are TRUE local wall clock at the site, and `utcOffsetSeconds` is
  per row.** Across a DST transition the offset changes mid-month, so the body-level
  `utcOffsetSeconds` is only the month-typical value — use the row's. Expect a
  fall-back month to contain the same wall-clock hour twice and a spring-forward
  month to be missing one: those hours genuinely occurred twice / never. Do not
  de-duplicate or backfill them.
- **The array is `hours`, not `hourlyWbgt`.** The forecast cache guard evicts any
  past-dated `hourlyWbgt` body; a history body is entirely past-dated, so the name
  is what keeps the cache from evicting itself.
- **No user options are applied.** Workload, shift window, standard and
  acclimatization are the caller's job — that is why one month is cached once and
  reused for every option set.
- **ERA5 is a model reconstruction, not a measurement.** It smooths urban heat
  islands and coastal gradients, which understates WBGT at an urban worksite. Say
  so when you present it, and use `/api/v1/wbgt` for on-site decisions.

An incomplete month (ERA5 lags real time by ~5 days) or one older than 10 years is
a `400` naming the problem — never a nearby month returned under the label asked
for.

### `GET /api/v1/weather/wbgt-seasonal?lat=&lon=`
Per-month statistics of daily maximum air temperature from the ECMWF SEAS5
51-member seasonal ensemble: `{ "months": [{ month, tmaxMeanC, tmaxP10C,
tmaxP90C, memberCount }] }`.

**This is not a WBGT source and no WBGT may be derived from it.** SEAS5 is
daily-resolution and carries no hourly wind, solar radiation or humidity — three of
the four inputs the WBGT estimator needs. Use it only to label a month as warmer or
cooler than normal alongside a climatology built from real ERA5 hours. `tmaxP10C` /
`tmaxP90C` are percentiles across ensemble MEMBERS of each member's monthly mean
(forecast disagreement about the month), not across days.

### `GET /api/v1/weather/air-quality?lat=&lon=&days=`
PM2.5-derived AQI (EPA 2024 breakpoints, `FORMULAS.pm25ToAqi`) for wildfire-smoke
overlays. Returns `{ "success": true, "provider": "Open-Meteo Air Quality",
"latitude", "longitude", "aqi": { "value", "pm25", "pm10", "sevKey", "label",
"guidance" } }`, plus a `daily` array when `days > 1`. `aqi` is `null` when the
provider returns no usable PM2.5 reading — check for that before reading
`aqi.value`. Note: `aqi.value` is PM2.5-specific, not
Open-Meteo's composite `us_aqi`. Feed it to `compare` as `aqiPm25` (or the raw
concentration as `pm25ugm3`) to activate the `smoke_overlay` standards.

### `GET /api/v1/geo/search?q=` or `?zip=&country=`
Geocode to candidate coordinates for the WBGT lookup. `zip` (postal code,
interpreted per `country` — US ZIP, Canadian FSA, UK outward code) goes through
zippopotam.us; otherwise `q` (place name, 2+ chars) goes through Open-Meteo
Geocoding. Returns `{ "success": true, "results": [ ... ] }` — possibly an empty
array. Missing both `q` and `zip` is a `400`; upstream failure is a `502`.

### `GET /api/v1/geo/country`
Cloudflare-detected caller location, with **no `success` envelope**:
`{ "country": "US", "regionCode": "CA", "region": "California", "latitude": 37.77, "longitude": -122.42 }`.
The coordinates are IP-level and city-scale — coarse by design. Every field is
`null` in local Express mode, and outside Cloudflare.

### National heat-alert feeds
Forecaster-judged alert levels, zone-specific, never computed from temperature.
Each takes `lat` and `lon` and covers one country only:

- `GET /api/v1/weather/es-heat-alert` — official AEMET *aviso* (Temperaturas máximas). Feeds `spain_rd_486_1997`'s `aemetAlertLevel` (RDL 4/2023 §3). `status: "none"` is returned **only** on a clean 200 with the point resolved; any fetch failure is an error, not a silent "no alert".
- `GET /api/v1/weather/fr-vigilance` — official Météo-France *canicule* vigilance (`vert`/`jaune`/`orange`/`rouge`) by department. Feeds `france_decret_2025_482`'s `meteoFranceVigilance` (Décret 2025-482 / Arrêté du 27 mai 2025).
- `GET /api/v1/weather/at-heat-alert` — official GeoSphere Austria heat warning level (1–4). Intended for `austria_hitzeschutz_verordnung`'s `geoSphereWarningLevel`, but see the known-limitation note above: that input is not currently accepted by `/api/v1/compare`, so today this endpoint is informational for API callers.

## Recommended agent workflow

1. `GET /api/v1/standards` once; cache `id` + `requiredInputs` + `ruleType`.
2. Collect at minimum `wbgtC` (or `wbgtF`) and `workload`. If you only have a location, call `GET /api/v1/wbgt` first and use its `wbgt.wbgtC` (flag it as estimated). Add `shiftStart`/`shiftEnd`/`date` — many rules (Gulf midday bans, the LIN default protocol) need them.
3. For a Spanish or French worksite, fetch the matching alert feed and pass `aemetAlertLevel` / `meteoFranceVigilance`.
4. `POST /api/v1/compare` with the inputs (add `standardIds` to narrow, or `guidance/country` to localize).
5. Report `composite` as the headline — check `stopWork` before reading minutes — cite the `contributingStandards`, and list any high-value standards that returned `missingInputs`.
6. Include `derivedOutputs.hydration` and `derivedOutputs.akiRisk` when advising a worker, and always pass through `warnings` / `confidence` / `inputDataQuality`.
7. Keep wildfire-smoke guidance in its own section, sourced from `smoke_overlay` results — never folded into the work/rest number.

## Errors

| HTTP | Meaning | Agent action |
|---|---|---|
| 400 | Validation failure; `errors[]` lists each problem (bad enum, out-of-range, unknown `standardId`, malformed date/time, missing `q`/`zip`). | Fix inputs and retry. |
| 403 | Missing/short/crawler UA, verified bot, or proxy-hosting ASN. | Set a proper `User-Agent`; do not use a crawler identity. |
| 405 | Wrong method (e.g. `GET` on `/api/v1/compare`). | Check the verb against this doc. |
| 429 | Rate limited. | Wait `Retry-After` seconds, then retry. |
| 500 | Server error. | Retry once with backoff; report if persistent. |
| 502 | Upstream provider failed (Open-Meteo, NWS, AEMET, Météo-France, GeoSphere, geocoder). | Retry with backoff; if it persists, fall back to a measured WBGT and say the forecast is unavailable. |

## Minimal example

```bash
curl -X POST "https://heatrules.com/api/v1/compare" \
  -H "Content-Type: application/json" \
  -H "User-Agent: HeatAgent/1.0 (+https://example.com)" \
  --data '{
    "input": { "wbgtC": 31.5, "workload": "moderate", "acclimatization": "unacclimatized",
               "shiftStart": "07:00", "shiftEnd": "15:00", "date": "2026-07-15" }
  }'
```
