frostwatch
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@frostwatchcheck frost risk for block 12 tonight and when to start the fans"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Frostwatch
Know whether to start the wind machines — before dawn, not after.
Live App · GitHub · API health · Agent · Issues
A grower does not need to be told the forecast. They need to know whether tonight's cold will damage the block, when to start the fans, and — six weeks later — whether that decision is still the one they made.
Frostwatch pulls the real overnight forecast for a real block, scores frost risk with a deterministic engine you can disagree with one factor at a time, records the decision, and seals it into a hash chain that still replays months later.
This is automated guidance from a public forecast, not an agronomic recommendation. Confirm with your own field sensors and local extension advice before acting.
✨ Features
Outcome you get | How |
Know whether tonight is a frost night |
|
See why, not just a number | Five itemised factors with weight, observed value, contribution and a plain-English rationale |
Know when to start the fans | Cold windows are computed from the hourly trace, with exact start and end hours |
Defend a decision later | Every sheet keeps a SHA-384 chain; |
Hand the work to an agent | Six MCP tools over JSON-RPC 2.0, including two mutating ones that share the UI's code path |
Take something with you | A markdown frost brief with provenance timestamps and the chain head |
Related MCP server: @agledger/mcp-server
🚀 Quickstart
Requires Node 20+.
git clone https://github.com/aniruddhaadak80/frostwatch.git
cd frostwatch
npm install
npm run devOpen http://localhost:3000. No environment variables are needed. Local runs use an embedded
SQLite database at .data/frostwatch.db, created and seeded on first use.
Production needs exactly one variable:
Variable | Required | Meaning |
| production only | Hosted Postgres (Neon) connection string. Without it a production build refuses to start rather than writing to an ephemeral store. |
| no | Override the local SQLite file path. Defaults to |
npm run typecheck && npm run lint && npm run test && npm run build
npm run verify:live # real HTTP checks against the deployment📐 Architecture
graph LR
Browser["Browser<br/>server-rendered"] --> Pages["Next.js routes"]
Agent["External agent"] -->|JSON-RPC 2.0| Mcp["/api/mcp"]
Pages --> Services["Service layer"]
Mcp --> Services
Services --> Engine["engine.ts<br/>frost-risk@1.0.0"]
Services --> Repo["Store interface"]
Repo --> Neon["Neon Postgres<br/>production"]
Repo --> Lite["SQLite<br/>local + tests"]
Services --> Weather["weather.ts"]
Weather --> OM["Open-Meteo"]
Weather --> NWS["NOAA / NWS"]
classDef data fill:#22d3ee,color:#04212b,stroke:#0e7490
classDef ai fill:#a78bfa,color:#1e1035,stroke:#6d28d9
classDef ok fill:#34d399,color:#04231a,stroke:#047857
classDef ext fill:#fbbf24,color:#2b1d02,stroke:#b45309
classDef inf fill:#94a3b8,color:#0b1220,stroke:#475569
class OM,NWS ext
class Engine ai
class Neon,Lite,Repo ok
class Browser,Agent,Pages,Mcp,Services,Weather infOne engine, three callers. The browser, /api/engine and the assess_frost_risk MCP tool all
call assessFrostRisk from src/lib/engine.ts. There is no second implementation of the score.
🔌 Data pipeline and honest fallback
graph TB
Req["Block coordinates"] --> Fetch["fetchForecast<br/>8s timeout, 2 retries"]
Fetch --> Live{"HTTP 200 and<br/>usable hours?"}
Live -->|yes| Norm["Normalise to HourPoint<br/>drop missing fields, never coerce"]
Live -->|no| Sample["Bundled sample<br/>status = fallback"]
Norm --> Src["status = live<br/>+ fetchedAt + sourceUrl + attribution"]
Sample --> Src
Src --> Engine2["assessFrostRisk"]
Alerts["NWS active alerts"] --> Src
classDef data fill:#22d3ee,color:#04212b,stroke:#0e7490
classDef ok fill:#34d399,color:#04231a,stroke:#047857
classDef risk fill:#fb7185,color:#2b0a0a,stroke:#be123c
classDef ext fill:#fbbf24,color:#2b1d02,stroke:#b45309
classDef ai fill:#a78bfa,color:#1e1035,stroke:#6d28d9
class Norm,Src data
class Live,Sample risk
class Fetch,Alerts ext
class Engine2 aiA missing cloud_cover is dropped, not coerced to zero, because "unknown cloud" read as
"clear sky" would fabricate frost risk. A fallback response is always labelled status: "fallback" and carries a note explaining why, and a fallback can never overwrite a record a
person created.
🧮 The deterministic engine
graph TB
In["Hourly points + threshold + crop stage"] --> Norm["Effective threshold =<br/>threshold − stage tolerance"]
Norm --> M["minimum 40%"]
Norm --> D["duration 20%"]
Norm --> R["radiation 15%<br/>only when near freezing"]
Norm --> W["wind 15%<br/>only when near freezing"]
Norm --> T["wetness 10%"]
M --> Sum["Σ score×weight ÷ 100"]
D --> Sum
R --> Sum
W --> Sum
T --> Sum
Sum --> Band["band + recommendation"]
Band --> Out["versioned result + itemised factors"]
classDef ai fill:#a78bfa,color:#1e1035,stroke:#6d28d9
classDef data fill:#22d3ee,color:#04212b,stroke:#0e7490
classDef ok fill:#34d399,color:#04231a,stroke:#047857
class In,Norm data
class M,D,R,W,T,Sum,Band ai
class Out okFixed integer weights summing to 100, so the score reads as a percentage and the combine step cannot drift between platforms. Radiation and wind are modifiers of frost rather than sources of it, so they only score once the night is within 3 °C of freezing — otherwise a clear, still summer evening would report frost risk.
Crop stages differ in how much cold they absorb: flowering and fruit-set are stressed 0.5 °C below their stated threshold, veraison 2 °C.
🛡️ Integrity: seals and replay
graph TB
G["genesis = 96 zeroes"] --> E1["event 1<br/>create"]
E1 --> S1["seal₁ = SHA-384(prevSeal ‖ canonicalJson(event₁))"]
S1 --> E2["event 2<br/>decision"]
E2 --> S2["seal₂ = SHA-384(seal₁ ‖ canonicalJson(event₂))"]
S2 --> E3["event n …"]
E3 --> Replay["replay: recompute every seal,<br/>report the FIRST broken seq"]
Del["delete"] --> Tomb["tombstone row,<br/>never remove"]
Tomb --> Replay
classDef ok fill:#34d399,color:#04231a,stroke:#047857
classDef risk fill:#fb7185,color:#2b0a0a,stroke:#be123c
classDef inf fill:#94a3b8,color:#0b1220,stroke:#475569
class S1,S2,Replay ok
class Del,Tomb risk
class G,E1,E2,E3 infcanonicalJson sorts object keys recursively so the same event always serialises to the same
bytes — otherwise adding a field could change a hash for reasons unrelated to the event's meaning.
Retiring a sheet writes a tombstone rather than deleting the row, so the chain still replays after
a removal. Replay reports the first broken sequence number, and whether the sheet's stored seal
still equals its recomputed head.
🤖 Agent interface
graph LR
A["Agent"] --> Init["initialize"]
Init --> List["tools/list"]
List --> Read["list_blocks<br/>list_sheets"]
List --> An["assess_frost_risk"]
List --> Mut["create_sheet<br/>update_sheet"]
Read --> Same["Same service layer<br/>as the UI"]
An --> Same
Mut --> Same
Same --> Store[("Postgres")]
Err["Unknown tool"] --> Code["JSON-RPC -32601"]
classDef ok fill:#34d399,color:#04231a,stroke:#047857
classDef inf fill:#94a3b8,color:#0b1220,stroke:#475569
classDef risk fill:#fb7185,color:#2b0a0a,stroke:#be123c
class A,Init,List,Same,Store ok
class Read,An,Mut inf
class Err,Code riskConfiguration is published at public/mcp.json:
{
"mcpServers": {
"frostwatch": {
"type": "http",
"url": "https://frostwatch.vercel.app/api/mcp",
"transport": "jsonrpc-2.0"
}
}
}Tool | Kind | Notes |
| read | Real coordinates, crop stage, thresholds |
| read | Scoped to the caller's session |
| analysis | Live forecast scored by the engine |
| mutating | Idempotent on (block, night) |
| mutating | Extends the audit chain |
| read | Recomputes the chain |
🔌 API
# Health, including the real store probe
curl -s https://frostwatch.vercel.app/api/health
# Live forecast + engine verdict for a real block
curl -s "https://frostwatch.vercel.app/api/forecast?block=blk-yakima"
# The engine on its own
curl -s -X POST https://frostwatch.vercel.app/api/engine \
-H 'content-type: application/json' \
-d '{"blockId":"blk-willamette"}'A full mutation with read-back:
BASE=https://frostwatch.vercel.app
# 1. create (the session cookie is what scopes ownership)
curl -s -c jar -b jar -X POST $BASE/api/sheets \
-H 'content-type: application/json' \
-d '{"blockId":"blk-yakima","nightOf":"2026-01-15","notes":"watching"}'
# 2. read back
curl -s -c jar -b jar $BASE/api/sheets/sh_xxxxxxxxxxxxxxxx
# 3. record a decision
curl -s -c jar -b jar -X PATCH $BASE/api/sheets/sh_xxxxxxxxxxxxxxxx \
-H 'content-type: application/json' \
-d '{"status":"protected","actor":"grower"}'
# 4. replay the audit chain
curl -s -c jar -b jar $BASE/api/integrity/sh_xxxxxxxxxxxxxxxx
# 5. export the brief
curl -s -c jar -b jar "$BASE/api/export?sheet=sh_xxxxxxxxxxxxxxxx" -o brief.md
# 6. retire (tombstone; the chain still replays)
curl -s -c jar -b jar -X DELETE $BASE/api/sheets/sh_xxxxxxxxxxxxxxxxErrors always look the same, so callers can branch on error.code:
{ "error": { "code": "SHEET_EXISTS", "message": "a sheet already exists for this block and night" } }Status | Code | Meaning |
400 |
| Body did not match the schema |
403 | — | never returned; another owner's sheet is a 404 |
404 |
| Not yours, or never existed |
409 |
| Conflict |
422 |
| Declaration failed validation |
503 |
| Production store unreachable |
👤 User journey
graph LR
Land["Land on /<br/>live scores"] --> Pick["Pick a block"]
Pick --> Lab["/effort<br/>drag the threshold lamp"]
Lab --> Rec["Record on a real sheet"]
Rec --> Sheet["/sheets/id<br/>record the decision"]
Sheet --> Verify["Replay the chain"]
Verify --> Brief["/export<br/>download the brief"]
Brief --> Agent["/agent<br/>same tools, JSON-RPC"]
classDef ok fill:#34d399,color:#04231a,stroke:#047857
classDef data fill:#22d3ee,color:#04212b,stroke:#0e7490
class Land,Lab,Rec data
class Sheet,Verify,Brief,Agent ok📁 Project map
Route | Goal |
| Live state for the first three blocks, scored on every load |
| Workspace: create a sheet, list yours, see seals |
| Dynamic detail: engine verdict, provenance, decision controls, audit trail |
| Analysis: the threshold lamp, a draggable verdict over the hourly trace |
| Agent console: six real JSON-RPC calls with raw responses |
| Takeaway: sealed markdown briefs and chain status |
API route | Method | Purpose |
| GET | Real store probe |
| GET | Live or labelled-fallback forecast + verdict |
| GET, POST | List and create |
| GET, PATCH, DELETE | Read, decide, retire |
| POST | Engine only |
| GET | Replay the chain |
| GET | Markdown brief download |
| POST | JSON-RPC 2.0: |
Module | Responsibility |
| The deterministic engine. Pure, versioned, itemised |
| Canonical JSON, SHA-384 chain, replay |
| Repository interface and adapter selection |
| Embedded adapter, local and tests |
| Hosted Postgres adapter |
| Fetching, normalisation, honest fallback |
| Anonymous owner id |
| Issues the owner cookie before render |
🔐 Security and reliability
No accounts. Ownership is a 128-bit id in an HTTP-only cookie, issued in
src/proxy.ts. A sheet belonging to another session returns 404, not 403, so ids cannot be probed.Every input is validated with zod and bounded. Queries are parameterised; no SQL is built by string concatenation.
External calls are allowlisted to
api.open-meteo.comandapi.weather.gov, 8-second timeouts, two bounded retries.A production build without
DATABASE_URLthrows instead of degrading to an ephemeral store.Per-session rate limiting is best-effort on serverless: clearing the cookie yields a new identity. Put a hosted limiter in front of
/api/sheetsand/api/mcpfor write-heavy use. See SECURITY.md.
🚀 Deployment
Vercel with the Neon integration attached. DATABASE_URL is injected by the integration; nothing
is committed. The deploy pipeline is: npm ci → typecheck → lint → test → build, then the
Node runtime serves the same engine.
graph LR
Push["Push to main"] --> CI["CI<br/>typecheck lint test build"]
CI --> Ver["Vercel production"]
Ver --> Neon2[("Neon Postgres")]
Ver --> Edge["Live routes"]
Edge --> Check["verify:live<br/>17 real HTTP checks"]
classDef ok fill:#34d399,color:#04231a,stroke:#047857
classDef inf fill:#94a3b8,color:#0b1220,stroke:#475569
class Push,CI,Ver,Edge,Check ok
class Neon2 inf🗺️ Roadmap
Now — shipped and verified:
graph LR
A["Live forecast"] --> B["Deterministic engine"]
B --> C["Sealed decisions"]
C --> D["Agent tools"]
classDef ok fill:#34d399,color:#04231a,stroke:#047857
class A,B,C,D okLive Open-Meteo + NWS data with an honest
live/fallbacklabelDeterministic, versioned, itemised frost-risk engine
Append-only SHA-384 audit chain with replay
Six MCP tools, two of them mutating, idempotent on create
Next — user-visible outcomes:
graph TB
Multi["Block-level history<br/>see how a block behaved across nights"]
Team["Shared watch links<br/>a read-only token, no accounts"]
Push["Threshold alerts<br/>email or webhook when a score crosses a band"]
Multi --> Team --> Push
classDef next fill:#22d3ee,color:#04212b,stroke:#0e7490
class Multi,Team,Push nextCompare a block across nights so a grower can see whether tonight is unusual
Read-only share links, so a grower can hand a decision to an agronomist without an account
Threshold alerts when the score crosses a band, via webhook
Later — outcomes, not features for their own sake:
graph LR
Micro["Microclimate stations<br/>crowd-sourced sensor intake"]
Model["Damage models<br/>what this cold actually cost the block"]
Micro --> Model
classDef later fill:#94a3b8,color:#0b1220,stroke:#475569
class Micro,Model laterAccept readings from grower-installed sensors to sharpen the trace
Optional chilling-injury models per variety, clearly labelled as advisory
📊 Data attribution
Source | Use | Licence |
Hourly forecast | CC BY 4.0 | |
Active alerts | Public domain |
Both are public and need no API key. Every forecast response carries its fetch time, source URL and licence.
🤝 Contributing
See CONTRIBUTING.md. The four rules that matter: one engine, append-only integrity, honest provenance, and never a silent production fallback.
📄 License
MIT — see LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Evidence-graded agent-work lanes, bid advice, live agent jobs and a hash-chained evidence ledger.
Scoped agent execution. Server-side credentials, policy, budgets and verifiable receipts.
Biotech rNPV/PoS engine for AI agents. Signed exports, evidence register, asset landscape.
Pay-per-call DeFi and macro intel for AI agents. x402 USDC tools via streamable HTTP /api/mcp.
Related MCP Servers
- AlicenseAqualityCmaintenanceEvery agent action is recorded in a SHA-256 hash chain. Prove to clients that your agent did what it said it did. Record, query, verify, and export agent activity.346 npm1MIT

@agledger/mcp-serverofficial
FlicenseAqualityBmaintenanceConnects MCP-compatible AI agents to the AGLedger API for change control, recording every change with signed, hash-chained records. Provides API pass-through tools and an offline audit verifier.3216 npm-- AlicenseNot gradedqualityBmaintenanceEnables AI agents to query live earthquake and disaster event data, and to compute deterministic risk assessments with tamper-evident seals.MIT
- AlicenseNot gradedqualityCmaintenanceEnables agents to query live NOAA/USGS space-weather and earthquake feeds, compute deterministic storm-risk assessments with hash-chained integrity seals, and verify data authenticity via JSON-RPC tools.MIT