Amanat

MCP serverDev tools

Storm risk, route risk and reanalysis backtests for any point on earth. No key, no wallet.

Unavailable. This server has no hosted endpoint yet, so ahel can't serve it.

Connect ahel once, and every AI you use reads what you have installed.

From the project's README

As published by pugarhuda/amanat in README.md.

Signed weather intelligence that a contract acts on by itself.

Signed, not "verified": the network's own verified: true cannot be checked from outside (bug report), so every answer here carries an Ed25519 signature over the fields a contract settles on, and anyone can check it with Node and nothing else:

node -e 'const c=require("node:crypto");fetch("https://amanat-miner.vercel.app/forecast?lat=10.32&lon=123.89&hours=6").then(r=>r.json()).then(({attestation:a})=>console.log(c.verify(null,Buffer.from(a.canonical),c.createPublicKey({key:Buffer.from(a.public_key,"base64"),format:"der",type:"spki"}),Buffer.from(a.signature,"base64"))))'

The key it checks against is the one published at /.well-known/amanat.json.

An amanat is a message entrusted to be carried — and, in the language of the old telegraph offices, the dispatch itself. That is the whole shape of this project: a miner sends the amanat, a scoring module tests it, and a contract carries it out without anyone deciding anything by hand.

Built for Telegraph Hackathon Season I. One codebase, three entries:

Live: amanat-miner.vercel.app — read a storm risk for any point, no wallet, no sign-up. It is the same call the contract makes before it spends anything.

In one line, from any MCP client: npx -y amanat-mcp — published to npm and listed in the official MCP registry as io.github.PugarHuda/amanat.

In three minutes: the deck · the film (84 s, cut from live sessions — no mockups) · /api/jobable, which measures how many intents are closed to on-chain jobs, how many are open, and how many cannot be audited at all because their leader publishes its registration at 127.0.0.1. Add ?intent=STORM_ALERT for a verdict on your own.

TrackWhatWhere
1 — MinerWeather and storm-risk miner, answers legible to both a text scorer and a smart contractminer/
2 — Script AuthorMeasurement-grounded WASM scoring module for Tier A intentsscorer/
3 — ApplicationParametric cover settled through ERC-8183 on-chain jobs, a CLI, and an MCP serveronchain/, agent/, app/, mcp/

The three problems this is built around

Everything here comes from measuring the live network rather than guessing at it. The numbers below are reproducible with the scripts in this repo.

1. Deterministic intents are scored as if they were prose. In epoch 240 the rank-1 miner on WEATHER_CHECK scored 0.0206 and on STORM_ALERT 0.0067. The miners are not bad; every scoring module on the network compares text, and those miners answer with numbers. A leaderboard built from those scores is noise — and routing follows the leaderboard.

2. The on-chain rail is real but unused. The Diamond answers getJobBasePrice() with 1000000 and, on 21 August, carried 139 miner registrations against 6 ERC-8183 jobs in its entire lifetime — the last two by a single participant, on 17 August. In a 77-hour window there were 44 MinerRegistered events and 2 JobCreated. Meanwhile the organisers name on-chain intelligence pipelines as the highest-value thing to build.

Read again on 31 August: 341 miner registrations, and still only 14 jobs. Eight of the fourteen are ours. Five settled; the last three have not moved.

3. Almost no miner can receive a job at all. A job hands the node raw OnChainData arrays; without an on_chain.request block in its YAML the node cannot turn those into an HTTP call. npm run audit fetches every registered YAML and checks. On 31 August: of 125 live miners, 29 declare an on_chain block at all, and a name-hashable intent has between one and three job-able miners — STORM_ALERT has exactly one, and it is this one. That is the bottleneck under problem 2, and it has loosened rather than closed: on 21 August there were 63 live miners and every name-hashable intent had a single job-able miner.

What the network actually looks like (read from /api/epochs, /api/validators and the Diamond, 21 August): epochs run hourly on testnet, not the 24 hours the docs describe. Each one scores 70 results across 17 intents and 29 of the 66 registered miners — more than half are never scored at all. There is one active validator, telegraph-node-1, so the 43-of-64 BFT threshold is a mainnet property, not something running today.

The shape held; the size did not. At epoch 294 on 30 August the network scored 242 results across 42 intents and all 125 live miners, and epochs had stopped being hourly — the last five landed 3 to 9 hours apart. Still one validator.


Track 1 — the miner

miner/server.mjs reads three free, keyless sources and returns every answer in two shapes at once. Open-Meteo's weather model gives the air; its marine model gives significant wave height, which is the thing that actually stops a ship and the one figure the shipping-lane board was missing; and GDACS gives every active named tropical cyclone on Earth with its position and maximum wind, so a reading under Tropical Storm Dolly says so by name rather than reporting "38 km/h wind". Storm risk is the worst of wind, gusts, rain, waves and cyclone proximity — a 4 m sea or a typhoon overhead reaches the ceiling on its own.

{
  "summary": "Weather forecast for -6.20, 106.85: the temperature is 27.8 °C (82 °F) and it feels like 32.3 °C, humidity 73%, Clear sky, cloud cover 0%, wind 4.4 km/h (1.2 m/s) from the south-east, gusts 11.9 km/h, precipitation 0.0 mm (2% chance of rain), valid at 2026-08-30T17:00Z. … Storm risk is low (0.132); across 51 ensemble runs it ranges 0.08 to 0.14, 0% of them over the trigger.",
  "temp_c": 27.8, "wind_kmh": 4.4, "gust_kmh": 11.9, "precip_mm": 0,
  "wave_cm": 0, "cyclone_name": null, "cyclone_km": 0,
  "risk": 0.132, "breach": false, "valid_at": "2026-08-30T17:00Z", "source": "open-meteo",
  "risk_band": { "model": "ecmwf_ifs025", "members": 51, "p10": 0.076, "p50": 0.104, "p90": 0.14, "max": 0.164, "breach_probability": 0 },
  "attestation": { "algorithm": "ed25519", "sha256": "9e991dc5…", "signature": "B3dtTlG1…", "public_key": "MCowBQYDK2VwAyEAJf1zypC6…", "key_persistent": true }
}

Abridged: a live response carries 36 fields. The rest are the ones a report carries and a contract ignores — humidity, dew point, wind direction, cloud cover, chance of rain, a two-day high and low, sea level, the named cyclone and its distance — plus the signed canonical payload and Open-Meteo's attribution. curl -s -X POST https://amanat-miner.vercel.app/forecast -H 'content-type: application/json' -d '{"lat":-6.2,"lon":106.85,"hours":0}' for the whole thing.

The sentence is what a text-comparing scorer can grade. The scalars are what Amanat.sol acts on, mapped through on_chain.fields in amanat-miner.yaml. Serving only one of the two is why the network currently has the gap it does.

The YAML also carries a complete on_chain.request block, which is what makes the miner reachable from a job at all, and declares Open-Meteo's real quota so the node refuses a request that would exhaust it before charging the caller.

npm run miner        # http://127.0.0.1:8787
node miner/test.mjs  # self-check, hits the real upstream

Hosting it

The miner has no dependencies, so the image is the runtime plus one file.

docker build -t amanat-miner miner/ && docker run -p 8080:8080 amanat-miner
fly deploy -c miner/fly.toml                    # or
cd miner && vercel deploy --prod                # serverless, via miner/api/*

Whichever you pick, the host has to be live before registerMiner: the node sandbox-tests every declared endpoint against the real upstream, and a registration whose YAML fails validation is rejected terminally rather than retried.

Track 2 — the scoring module

scorer/ is a no_std Rust module compiled to wasm32-unknown-unknown: 15.5 KB, zero imports, exporting alloc, dealloc, rank_answer and breakdown_answer.

It reads the quantities out of an answer and grades them as measurements: 38.2 °C, 100.8 F and 311.35 K are one reading; 10 m/s and 36 km/h are one wind speed; being 0.3° out is right and 30° out is wrong. Text overlap stays, but only to carry the non-numeric part of an answer.

Two rules do most of the anti-gaming work:

  • A number the question already stated earns nothing when it comes back. "Will it exceed 40 °C?" answered with "40 °C" is the prompt, not a reading — unless the ground truth also says 40.
  • Committing beats covering. Listing every candidate value is charged for, so a hedged answer cannot outscore a decision.

Every float operation is + - * / and comparison, which IEEE-754 defines exactly, so two validators on different hosts return identical bits.

npm run build:scorer
npm run bench      # against the real champion binaries
npm run attacks
cd scorer && cargo test    # 27 tests, native

Measured on scorer/bench.json (38 good/bad cases across 31 intents, 14 attacks) against champion binaries downloaded from their published wasm_url, re-run 31 August:

modulemarginwinsworst self-matchstddev
game_result reg 1265 — reigning0.652437/381.00000.4525
amanat_scorer0.597737/381.00000.4343
urlscan reg 28 — champion until 23 August0.501534/381.00000.3320
weathercheck reg 134 — superseded0.468834/381.00000.3228
weather_forecast reg 636 — reigning0.443833/381.00000.4277
financial reg 122 — superseded0.388629/381.00000.4191
weather_check reg 510 — reigning0.382733/380.99520.3900
storm_alert reg 453 — reigning0.307135/380.98300.3586
text_auth reg 1882 — reigning0.289529/381.00000.4472

Stage 2 needs both bars — margin and ordering wins at least matching the champion — so the wins column matters as much as the margin.

The top row is the one to read first: registration 1265, the module that took the GAME_RESULT slot back off us after forty minutes, beats this module on this corpus. It is a good module. The three seated on the weather intents are not, and that is the gap this project is about.

Reproducing this table needs one step the repo cannot do for you. scorer/champions/*.wasm is gitignored — they are other people's binaries, some of them 25 MB — so a clean clone has nothing to compare against and npm run bench will report only our own. Fetch them first from the wasm_url each registration publishes at https://devnode.telegraphprotocol.com/api/wasm and drop them in scorer/champions/. npm run attacks reads scorer/target/…/amanat_scorer.wasm, so it wants npm run build:scorer first.

Read the rest honestly: this corpus is ours, and it says the approach works on the cases we can see, not that it wins the protocol's own hidden fixtures.

Widening the corpus from 20 cases to 38 is what found the real bugs. It exposed that "812.4 million" parsed as 812.4 while "812.4M" parsed correctly, that a correct paraphrase omitting a figure scored below a wrong answer that quoted it, and that "not a human" was being read as a negative verdict on an AI-detection question. Each of those was a wrong rule, not a missing special case.

The gate nobody talks about

Beating the champion's margin is not sufficient on an intent that carries traffic: the node also checks that your module orders real miner answers roughly the way the champion does, and rejects below about 0.60. That is why a 0.68-margin module was refused on WEB_SEARCH while a 0.388 one went live.

node scorer/harness.mjs --agreement <ours.wasm> <champion.wasm>
node scorer/harness.mjs --diff      <ours.wasm> <champion.wasm>   # where we lose

Against the then-reigning URL_SCAN champion, registration 28, we sat at 0.92 mean rank agreement when this was measured on 21 August, and the cases where we diverged most were WEATHER_CHECK and WEATHER_FORECAST — exactly where we meant to. That binary was superseded on 23 August; the figure has not been re-measured against the modules seated now.

Anti-gaming: 14/14 attacks held. The last one to fall needed a third signal — a verdict. A negator flips the next verdict word inside its own clause, so "no malicious behaviour" reads positive, while "No, it is a phishing page" is itself the verdict because a clause break ends the negator's reach. An answer that commits the other way from the ground truth keeps 15% of its score, however much of the right vocabulary it carries. That closed the keyword dump (0.84 to 0.13 against an honest 0.53) and raised the benchmark margin at the same time, which is the shape of a real signal rather than a patch. A second rule follows from it: an answer that asserts both poles — "valid ... however it is invalid" — has hedged rather than answered, and is charged for it.

The reg-28 binary, champion until 23 August, leaks the case this module was built to catch — wrong dimension, same number, where it scores "12 °C" at 0.80 against an honest "12 millimetres" at 0.66.

Track 3 — the application

onchain/src/Amanat.sol is a parametric weather cover where the contract is the customer of the intelligence, not a front end calling an API:

openPolicy()  →  requestCheck()  →  createJob(keccak256("STORM_ALERT"), params, this)
                                      ↓  protocol routes to the best-ranked miner
                                      ↓  validators finalise
                              subnetMessage()  →  pay the holder, or decline

Two design constraints drove it, and neither has a workaround:

  • The answer arrives from a miner nobody here chose. OnChainData is packed according to that miner's YAML, so the contract validates what arrived and declines a claim whose shape it cannot read. It never guesses at a payout.
  • Delivery is asynchronous and not guaranteed. Funds stay escrowed against the policy, and expire() releases them after 24 hours if no answer lands, so a silent rail cannot hold the book hostage.

agent/ is the loop that feeds it, cheapest rail first — the daemon feed is free, an Engine call is $0.01, a job is $1.00, so nothing goes on-chain until the cheap rails say a policy is worth settling.

npm run agent:dry    # read-only: no wallet, no funds, no spend
npm run agent        # opens jobs for policies that pass screening

Three other consumers of the same miner, so the contract is not the only thing that can act on a reading.

  • app/storm.mjs — a zero-dependency CLI. storm "Cebu" reads a risk; storm route "Cebu" "Manila" reads a voyage; --telegraph asks the network instead of this miner and prints which one answered.

  • mcp/server.mjs — an MCP server, published to npm and listed in the official registry as io.github.PugarHuda/amanat. Four tools, no key, no wallet:

    { "mcpServers": { "amanat": { "command": "npx", "args": ["-y", "amanat-mcp"] } } }
    
  • The storm board — ten shipping lanes screened every six hours, published to a branch and served at /api/board. Between 26 August and 6 September the scheduled run bought its readings through the Telegraph Engine, 30 to 86 paid calls a run, routed by the node to whichever miner it ranked best — usually not this one, and the board published that tally per run.

    It stopped buying on 6 September. The protocol's co-founder asked in Discord that people stop their scripted automated calls, which were taking the payment facilitator down, and said scripted calls would not be counted in judging — only organic ones. Scheduled runs now read the free rail, which is this miner's own /forecast, so the schedule puts no load on the node and buys nothing. rail in board.json says which one produced a given run, and telegraph is null when nothing was bought. A paid sweep is still one click from the Actions tab with paid=true.

    The contract is unaffected: when a policy is checked on chain it buys its own reading through the Engine, because that is a real request from a real obligation rather than a schedule inflating a counter.

Why we only use name-hashed intents

agent/run.mjs deliberately targets name-hashed intents only (keccak256("STORM_ALERT")), so the protocol picks the miner. Using a registration-derived intentId would pin the job to our own miner, which is exactly the self-dealing loop the organisers warned against.


Layout

miner/     server.mjs, amanat-miner.yaml, test.mjs
scorer/    src/lib.rs, harness.mjs, bench.json, champions/
onchain/   Amanat.sol
agent/     telegraph.mjs, run.mjs, audit-jobable.mjs

scorer/harness.mjs loads any Telegraph scoring module the way a validator does — no imports, strings written through the module's own alloc — using Node's built-in WebAssembly, so comparing against a champion needs no extra toolchain.

On-chain so far

The loop closes. Amanat.sol is live at 0x4A5ECEBd…9893 — the address the page reads, holding a 3.00 USDC job budget and two open policies. The same source was verified on Sourcify at its previous address 0x0700c930…590c, creation and runtime bytecode both a full match, so what the chain runs is what this repo shows. It settles claims with nobody in the loop:

openPolicy -> requestCheck -> createJob(keccak256("STORM_ALERT"))
  -> protocol routes, validators finalise -> job Terminal
  -> subnetMessage -> risk below the 0.75 trigger -> Declined

Jobs 7, 8, 9 and 10. Before these, the chain had seen six ERC-8183 jobs in its entire lifetime.

All three rails run, cheapest first. That ordering is the cost design, not a description: the daemon feed is free, an Engine call over x402 is $0.01 and a job is $1.00, so the agent asks a hundred cheap questions before it asks one expensive one. It screens every open policy the contract still owes on, and goes on-chain only for a policy the cheap answer puts near its trigger. One run: 42 paid Engine calls, one escalation, $1.42.

A finding the second job proved

Policy 2 was written at latitude 1 and policy 3 at 10.32. Both came back risk 0.382, forecast for 0.00, 0.00. The contract stored the coordinates correctly and passed them in strings[0..1]; the YAML maps them from strings.0 and strings.1. They do not survive the node's on_chain.request mapping.

On job 9 it changed the outcome. The Engine screen for Manila read risk 0.488, above the escalation threshold; the job opened for that same policy came back 0.361, the value for Null Island, and the contract declined the claim. A contract that paid a dollar for a signal acted on a reading of somewhere else. Written up in docs/bug-report.md.

Previous deployment. 0x1649ce04B8b9D56285a62Afb2b442602EE0bBc6e ran the same contract before it adopted SafeERC20, and its eleven policies and five settled jobs — 7 through 11 — are still readable on Base Sepolia. It was replaced rather than left in place because the tests only prove the code in this repo, and a deployment running different code from the one under test is the sort of gap this project keeps finding in other people's systems.

Two things that cost a transaction to learn

createJob draws on the escrow of whoever calls it — the contract, not the wallet that deployed it. Hence fundEscrow() and jobBudget().

The public Base Sepolia RPC serves its own writes back stale. A confirmed transfer read as a zero balance, a confirmed approve simulated as exceeds allowance, and a requestCheck that reverted on estimateGas while the same call returned jobId 7 when simulated directly one command later. BASE_SEPOLIA_RPC points at publicnode now.

Miner and scoring modules

Miner: first registered as 179 on 24 August; the live registration is 280. amanat-weather-risk, serving WEATHER_FORECAST, WEATHER_CHECK and STORM_ALERT from https://amanat-miner.vercel.app, floor 0.01 USDC. Every updateMiner supersedes the id before it, so 179 and 256 read deregistered now and 280 is the one the node routes to.

Getting there cost one terminal rejection worth writing down: every on_chain.fields entry requires a description. The schema enforces it, the docs' own direct-transform example omits it, and a miner rejected for it is not retried — the repair is updateMiner, not a fresh registerMiner. The pre-flight in agent/register-miner.mjs now refuses to spend a registration on a YAML missing one.

Three Vercel failures preceded that, each different: every .mjs at the deploy root is treated as a function entry point, so a helper module crashes the deployment; ignoring it removes the entrypoint entirely, because this is a Node server project and server.mjs is exactly what Vercel looks for; and it wants that file's default export to be the server.

Scoring modules: four champion slots held, then lost to a protocol change.

IntentRegOur marginBeat
CRYPTO_PRICE2010.84490.7961
WEATHER_CHECK1910.84450.7928
STORM_ALERT1880.83550.7585
URL_SCAN2020.82570.7892

On 23 August the protocol shipped a fix so each module is evaluated against its own registered intent rather than one shared fixture set. Registrations across the network went from 187 to 574 as everyone re-registered, and all four of our slots were superseded. The fixture sets are visibly per-intent now: WEATHER_CHECK reports 12 cases and WEATHER_FORECAST 15, where everything used to report 32.

That is the right change, and it invalidates the finding this repo previously recorded — that one fixture set was shared across all 45 intents, proven at the time by identical scores to seven decimal places for one binary across different intents. It was true, and it is no longer.

What the rejections taught

186 lost by 0.017 on STORM_ALERT. It tied the champion on ordering and lost on separation alone, so the fix was contrast rather than judgement: smoothstep repeated. A strictly increasing curve cannot reorder a pair, so ordering and rank agreement are untouched by construction while the good/bad gap widens. 0.7415 to 0.8355.

A binary is burned for the address that registered it. Re-registering bytes we had already used reverts with duplicate wasm hash even after that entry is superseded or rejected. A new slot needs a new build.

203 was rejected on WEATHER_FORECAST for being right. It beat the champion on the fixtures — 0.8349 against 0.7859 — and was refused for ranking 91 real miner answers differently:

disagreed with the champion on real traffic: agreement -0.2585, need at least 0.60

Negatively correlated with an incumbent whose scores for weather answers sit near zero — rank 1 on WEATHER_CHECK scored 0.0206 in epoch 240. Disagreeing with noise produces a negative correlation with it. WEATHER_CHECK squeaked through at 0.6111 against a 0.60 threshold; WEATHER_FORECAST, same domain, different incumbent, could not. The gate rewards conformity with whatever is seated, which is backwards precisely when the seated module is the weak one.

Shortened here. Read the whole README on GitHub.

Signals

Last commit
Sep 2026
Weekly downloads
631
Advanced
Delivery
amanat MCP server → your ahel gateway (mcp.ahel.ai) → every connected AI client.
Catalog kind
mcp-server
Gateway key
io-github-pugarhuda-amanat
Source
github.com/pugarhuda/amanat