Skip to main content
Glama
README.md
# WeatherWake MCP

Park a weather condition instead of polling for it. An agent calls `register_wake` with a site and a rule such as "gust < 30 km/h and no precipitation for 3 h, within the next 48 h". The call returns at once and the session can end. A small local worker checks the forecast on a budgeted cadence. When the forecast shows the window (or the deadline passes), it POSTs a signed webhook so a durable workflow resumes.

> **Forecast-based trigger, not a safety authority.** A `wake.met` webhook means a forecast showed the window you asked for. Forecasts can be wrong. The receiving workflow and the responsible person decide whether work goes ahead.

[![CI](https://github.com/SkilledDragonCorp/weatherwake-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/SkilledDragonCorp/weatherwake-mcp/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE)

## The problem

Agents that wait on weather tend to poll it inside a long chat ("check again in an hour") and keep an expensive session open while the sky decides. The waiting belongs outside the model. Register the condition once, let a cheap process watch the forecast within an API budget, and wake the workflow with a webhook that the receiver can verify.

## How it works

```text
agent --register_wake--> MCP server --writes--> .weatherwake/wakes/<id>.json   (returns in ~2 ms)
                                                     |
                          weatherwake worker (loop or cron) -- forecast: NWS / Google / synthetic
                                                     |   (daily call budget, per-site cache, 30-180 min cadence)
                          condition met or expired --+--> outbox (durable, retries)
                                                     |
                          POST WEATHERWAKE_WEBHOOK_URL   webhook-id / webhook-timestamp / webhook-signature
                                                     |
                          receiver: verify HMAC + timestamp + replay guard --> enqueue resume job (idempotent per wake)
                                                     |
                          resume worker / your workflow engine continues the parked run
```

A wake is **met** when the forecast shows at least `for_hours` consecutive hours where every rule holds. The window must end before `expires_at` and start within `lead_hours` of now, and it must be seen on `confirmations` consecutive checks (spaced at least 60 min apart). A wake **expires** once no such window can still fit before the deadline. That check makes no upstream call.

Cadence: with no window in the forecast, the worker checks again after the max interval (180 min). With a window further out, it checks again `lead_hours` before the window starts, clamped to 30-180 min, because forecasts move. While it waits for confirmations, the spacing is 60 min. Wakes on the same site share one cached forecast for 20 min. Every upstream request first takes a unit from a per-UTC-day budget (default 200). Errors that will not fix themselves (outside NWS coverage, a missing key, the API not enabled) back off to the max interval.

## Quickstart

```bash
bun install --frozen-lockfile
bun run demo                       # keyless: simulated clock + synthetic forecast; real MCP, worker, HTTP, receiver, queue
```

Live (keyless, US only, via the National Weather Service):

```bash
cp .env.example .env
bun run src/cli.ts secret          # paste into .env as WEATHERWAKE_WEBHOOK_SECRET (do not share it)
bun run receiver                   # terminal 1: verifying receiver on 127.0.0.1:8787
bun run worker                     # terminal 2: checks due wakes every 60 s, delivers webhooks
bun run src/cli.ts preview  --lat 35.7796 --lng -78.6382 --rule "wind_gust_kmh < 30" --rule "precip_mm <= 0" --for-hours 3 --source nws
bun run src/cli.ts register --lat 35.7796 --lng -78.6382 --rule "wind_gust_kmh < 30" --rule "precip_mm <= 0" \
  --for-hours 3 --within-hours 48 --resume '{"run":"crane-lift-42","step":"schedule-crew"}' --key crane-lift-42
```

### MCP server

```json
{
  "mcpServers": {
    "weatherwake": {
      "command": "bun",
      "args": ["/abs/path/weatherwake-mcp/src/cli.ts", "serve"],
      "env": { "WEATHERWAKE_DATA_DIR": "/abs/path/state", "WEATHERWAKE_WEBHOOK_URL": "https://your-app.example/hooks/weatherwake" }
    }
  }
}
```

Put the signing secret (and an optional `GOOGLE_MAPS_API_KEY`) in the environment of both the server and the worker. Do not put them in the MCP config you share. The worker must use the same `WEATHERWAKE_DATA_DIR`.

| Tool | Input | Upstream calls |
| --- | --- | --- |
| `register_wake` | `lat`, `lng`, `rules[]`, `for_hours` (1-24, default 1), `within_hours` (1-240, default 48), `lead_hours` (0-48, default 1), `confirmations` (1-5, default 1), `source` (`auto` / `nws` / `google` / `synthetic`), `resume` (JSON <= 8 KB, echoed in the webhook), `idempotency_key`, `label` | none (returns immediately) |
| `preview_condition` | site + condition | 1 forecast fetch (or a cache hit) |
| `get_wake` / `list_wakes` | `wake_id` / optional `state` | none |
| `cancel_wake` | `wake_id`, `reason` | none |
| `wake_status` | none | none. Reports budget used today, cadence, worker heartbeat (warns if no tick in 10 min), signing configured |

The tools take **no webhook URL**: the target comes from `WEATHERWAKE_WEBHOOK_URL`, so a prompt cannot redirect wake-ups to another host.

Rule grammar: `METRIC OP VALUE [missing ok|fail]`. Every rule must hold in an hour. A missing value fails the hour unless the rule says `missing ok`. The numeric metrics are `wind_gust_kmh`, `wind_speed_kmh`, `precip_mm`, `precip_probability_pct`, `thunderstorm_probability_pct`, `temperature_c`, `feels_like_c`, `cloud_cover_pct`, `relative_humidity_pct` and `visibility_km`; they take `< <= > >=`. The metrics `condition` and `precip_type` take `in` / `not_in` with UPPER_CASE lists and come from Google only. "No precipitation for 3 h" is `precip_mm <= 0` with `for_hours: 3`.

## The webhook

[Standard Webhooks](https://www.standardwebhooks.com/) format. Full field list and receiver contract: [docs/WEBHOOK.md](./docs/WEBHOOK.md).

```text
webhook-id:        msg_...            same id on every retry of one event
webhook-timestamp: 1793768400         unix seconds of this attempt
webhook-signature: v1,<base64 HMAC-SHA256(secret, "{id}.{timestamp}.{raw body}")>
```

- The body is canonical JSON (sorted keys, no whitespace). Its `type` is `wake.met` or `wake.expired`. It carries `wake_id`, `webhook_id` (equal to the header), `window` (start, end, the end of the clear run, and hour values), `condition`, `resume`, `source`, `attribution` and `stats`.
- The secret is `whsec_` + base64 of 24-64 bytes. Several secrets can be configured for rotation: the first signs, all verify.
- The receiver (`src/receiver.ts`, `examples/receiver.ts`) verifies the raw body in constant time. It rejects timestamps more than 300 s off and remembers accepted ids on disk, so a replay gets `200 duplicate` and does nothing. It then enqueues the resume job with idempotency key `wake:<wake_id>`.
- Interop: `tests/unit/signing.test.ts` checks that the reference `standardwebhooks` npm package (1.1.1) verifies our signatures and that we verify its signatures.
- Delivery: 2xx means done. 408, 429, 5xx and network errors are retried with the same id (backoff 30 s doubling, 8 attempts). Any other 4xx is permanent. The worker never sends an unsigned webhook.

### Durable workflow engines

[docs/WORKFLOW_MAPPING.md](./docs/WORKFLOW_MAPPING.md) maps the park / resume steps onto Vercel Workflow (`createHook` + `resumeHook` from `workflow/api`), Trigger.dev (`wait.createToken` / `wait.forToken` / `wait.completeToken`) and Hatchet (`ctx.waitForEvent` + `hatchet.events.push`). The mapping was written from each package's published type declarations and docs (`workflow` 5.2.3, `@trigger.dev/sdk` 4.7.6, `@hatchet-dev/typescript-sdk` 1.38.0 on npm, 2026-10-21). None of them is a dependency, and the snippets were not executed here.

## Measured

From `bun run report` (scripts/report.ts → [reports/weatherwake-mcp.md](./reports/weatherwake-mcp.md), [reports/weatherwake-mcp.json](./reports/weatherwake-mcp.json)) on Bun 1.4.3, darwin/x64. **The forecasts are synthetic** ([fixtures/sequences](./fixtures/sequences)) and run under a simulated clock. The MCP server, worker, cadence, budget, cache, HTTP delivery, signature checks, replay guard and resume queue are the real code. "15-min polling" is arithmetic for comparison (settle time / 15 min), not a measurement.

| Run | Wake | Event | Settled (h) | Window start (h) | Window holds in synthetic truth | Checks | Upstream calls | Cache hits | Budget deferrals | Delivery (attempts) | 15-min polling (computed) |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| storm-clears | storm-clears wake 1 | wake.met | 18 | 19 | yes | 7 | 7 | 0 | 0 | delivered (3) | 72 |
| never-clears | never-clears wake 1 | wake.expired | 46 | - | - | 16 | 16 | 0 | 0 | delivered (1) | 184 |
| flapping-c1 | flapping wake 1 | wake.met | 9 | 10 | **no** | 4 | 4 | 0 | 0 | delivered (1) | 36 |
| flapping-c2 | flapping wake 1 | wake.met | 30 | 30 | yes | 13 | 13 | 0 | 0 | delivered (1) | 120 |
| shared-site | gust<30 + dry, 3 h | wake.met | 18 | 19 | yes | 7 | 7 | 0 | 0 | delivered (1) | 72 |
| shared-site | gust<40, 2 h | wake.met | 16 | 17 | yes | 7 | 1 | 6 | 0 | delivered (1) | 64 |
| shared-site | dry + PoP<30, 6 h | wake.met | 17 | 18 | yes | 7 | 1 | 6 | 0 | delivered (1) | 68 |
| budget-cap | never-clears wake 1 | wake.expired | 46 | - | - | 17 | 14 | 0 | 3 | delivered (1) | 184 |

What the runs show:

- **storm-clears**: early forecasts put the clearing 6 h too late. The worker spaces checks 3 h apart and wakes at hour 18 for a window starting at hour 19, after 7 checks. The receiver answered 503 twice first, and the third attempt was accepted with the same `webhook-id`.
- **flapping**: one forecast issuance shows a calm spell that never happens. With `confirmations: 1` the wake fires on it, and the window does not hold in the synthetic truth. With `confirmations: 2` it fires 21 h later, on a window that holds.
- **shared-site**: three wakes on one site made 21 checks with 9 upstream calls.
- **budget-cap**: with `WEATHERWAKE_MAX_CALLS_PER_DAY=6`, 3 checks were deferred to the next UTC day instead of overspending.
- After delivery in the storm-clears run, the receiver answered the following probes. Replaying the same request got `200 duplicate`. A tampered body got `401 bad_signature`, and so did a wrong secret. The same request 10 min later got `401 timestamp_too_old`. A request with no signature got `401 missing_headers`.
- `register_wake` through the in-memory MCP client: p50 1.94 ms over 20 calls, no upstream calls (this machine; see the report).

Timeline of the storm-clears run: [reports/demo-timeline.md](./reports/demo-timeline.md).

Local gate (`bun run check`, this machine): install, skills layout, strict YAML, Biome, `tsc --noEmit`, 71 unit + keyless e2e tests, 3 live tests, report, gitleaks. All green. The live tests: NWS preview and a worker tick against api.weather.gov passed. The Google Weather test skipped with `403 SERVICE_DISABLED`, because the Weather API is not enabled in the key's project.

## Configuration

All optional; see [.env.example](./.env.example). Bun loads `.env` automatically.

| Variable | Default |
| --- | --- |
| `WEATHERWAKE_WEBHOOK_URL` | `http://127.0.0.1:8787/hooks/weatherwake` (https anywhere, http only for localhost) |
| `WEATHERWAKE_WEBHOOK_SECRET` | none: the worker refuses to deliver without one |
| `WEATHERWAKE_DATA_DIR` | `./.weatherwake` |
| `WEATHERWAKE_MAX_CALLS_PER_DAY` | 200 upstream calls per UTC day |
| `WEATHERWAKE_MIN_INTERVAL_MIN` / `MAX_INTERVAL_MIN` / `CONFIRM_INTERVAL_MIN` / `CACHE_TTL_MIN` | 30 / 180 / 60 / 20 |
| `WEATHERWAKE_MAX_ARMED` / `MAX_DELIVERY_ATTEMPTS` | 50 / 8 |
| `GOOGLE_MAPS_API_KEY` | none: `auto` then uses the keyless NWS |

## Tests

```bash
bun run test        # unit + keyless e2e: simulated scenarios, MCP tools, stdio server + worker processes, CLI
bun run test:live   # NWS (keyless) + Google Weather (skips without a key or on 403 SERVICE_DISABLED); WEATHERWAKE_LIVE=0 skips all
bun run check       # the full local gate (CI runs the same script, keyless)
```

## Limits

- **The forecast is the signal.** A wake fires on a forecast window, not on observed weather. The synthetic flapping run shows a false wake with `confirmations: 1`.
- **Sources.** NWS covers the US only; elsewhere you need a Google Maps Platform key with the Weather API enabled. The Google path is unit-tested against synthetic responses with the documented shape, but it was **not verified live** here: the Weather API is not enabled in the project used for this build (403 SERVICE_DISABLED). The rules `condition` / `precip_type` and thunderstorm probability need Google; NWS gives 6 h precipitation blocks, which are spread evenly per hour.
- **Sites are lat/lng only.** There is no geocoding.
- **The worker must be running** (a loop or cron `worker --once`). The MCP server alone never checks anything; `wake_status` warns when no worker has ticked.
- **Single host.** The state, outbox, replay guard and queues are files that rely on atomic `rename` on one filesystem.
- **Delivery retries** stop after 8 attempts (about an hour). That is shorter than the Standard Webhooks example schedule. After that the wake shows `delivery.status: failed`.
- **Not measured here:** token or dollar savings from ending the chat session, and whether Vercel Workflow, Trigger.dev or Hatchet bill a parked run. The idea's premise of an "idle 200K session" is not quantified by this repo.
- **Not included:** Gemini (named in the idea as optional; not used), adapters that call the workflow engines (the mapping doc only).

## Not affiliated

Not affiliated with, endorsed by, or sponsored by Google LLC, the US National Weather Service, Vercel Inc., Trigger.dev, Hatchet, or the Standard Webhooks project. Google Maps Platform is a trademark of Google LLC.

## License

Apache-2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE) (code reused from the SkilledDragonCorp labs weatherops-mcp and pausabudget-gate).

Built by [SkilledDragon LLC](https://skilleddragon.com) as an educational minilab. Questions: hello@skilleddragon.com.