Run it when the power is cheap.
Watches day-ahead electricity prices and the renewable-share forecast, works out the cheapest or greenest window that still meets your deadline, and starts your job there.
Zero runtime dependencies. No account, no API key, no build step. Everything stays on your machine.
git clone https://github.com/gledach/whenrun.git && cd whenrun
node bin/whenrun.mjs pricesThat works on a fresh clone with nothing installed. There is no npm install because there
is nothing to install.
German power prices move by a factor of three or more within a single day. These are real
figures from one evening, captured by whenrun prices:
today ▄ 176 EUR/MWh (1h left)
tomorrow ▆▅▅▅▅▅▇█▇▆▅▃▂▁▁▃▅▇██▇▅▅▄ 72 to 231 EUR/MWh
00:00 06:00 12:00 18:00
len when spot you pay
1h tomorrow 13:15 to 14:15 68.3 EUR/MWh 31.3 ct/kWh delivered
4h tomorrow 11:30 to 15:30 86.6 EUR/MWh 33.5 ct/kWh delivered
Greenest 2h tomorrow 12:30 to 14:30 101% renewable
Since January 2025 every German electricity supplier has had to offer a dynamic tariff, and the day-ahead auction is public. The information has been free the whole time. What was missing was something that acts on it without tying you to one supplier's app.
Ships set to 82319 Starnberg, bidding zone DE-LU. To see that and every zone it supports:
whenrun whereTo change it for good:
cp config/location.default.mjs config/location.local.mjsThen edit the copy. It is gitignored, so it is also the right place for your postcode if you
would rather that were not in a published repository. For a single command, pass
--zone=AT.
Verified against live data for DE-LU, AT, FR, CH, NL, ES and PL. A bidding zone is not a country: Germany and Luxembourg share DE-LU, Denmark is split at the Great Belt, and Italy and Norway have several. The renewable-share forecast is published per country and is derived from the zone, so you only set one thing.
The postcode labels output and nothing else yet. Network fees vary by grid operator rather than by bidding zone and are still modelled as flat, which is roadmap item 2.
whenrun prices # today and tomorrow, with cheap windows
whenrun plan --duration=45m --kw=2 --by=07:00 # when should this run, what will it cost
whenrun plan --duration=2h --kw=11 --objective=greenest
whenrun plan # every task in your config
whenrun run --task=backup # dry run
whenrun run --task=backup --execute # wait for the window, then run it
whenrun daemon --execute # all enabled tasks, continuously
whenrun daemon --once --execute # one pass, for cron or Task Scheduler
whenrun report # what it actually saved
whenrun where # your location and every zone
whenrun doctor # config, connectivity, data freshnessThree objectives: cheapest minimises price, greenest maximises renewable share, and
balanced blends the two. A job marked interruptible may be scattered across
non-adjacent slots; otherwise it gets one unbroken block.
cp config/tasks.default.mjs config/tasks.local.mjs # what to run
cp config/tariff.default.mjs config/tariff.local.mjs # what you payBoth .local.mjs files are gitignored and override the defaults, so you can pull upstream
forever without a conflict in the files you edited.
A task looks like this:
{
id: 'backup',
label: 'Nightly backup',
command: 'npm run backup', // or { cmd, args } to skip the shell
durationMinutes: 45,
kw: 0.3, // average draw, used for the cost estimate
earliest: '20:00',
deadline: '07:00', // must be finished by then
objective: 'cheapest',
enabled: true,
}Times are a 24-hour clock in your local timezone, written HH:MM. So 19:00 is the
evening and 07:00 is the morning. There is no am or pm, and the leading zero is required:
7:00 is refused rather than guessed at. Everything the tool prints back uses the same
clock.
Your task is told what it was scheduled into, through the environment:
WHENRUN_WINDOW_START, WHENRUN_WINDOW_END, WHENRUN_SPOT_EUR_MWH,
WHENRUN_RENEWABLE_PCT, WHENRUN_SLOTS.
This tool runs commands unattended. The rules it holds to:
- The command comes from your task file. Never from a price feed, never from an argument, never from the network. There is no code path that builds a command out of fetched data.
- Two independent locks. A task needs
enabled: trueand the command needs--execute. Dry run is the default everywhere. - It refuses rather than guesses. Stale price data, an impossible deadline, or a window that closed while the daemon was asleep all produce a refusal with a reason, not a best effort.
- The MCP server is read-only. An agent can ask when power is cheap. It cannot start anything.
The figure everyone quotes is the spot price. It is not your bill. A German bill is roughly spot plus supplier markup plus grid fees plus levies plus taxes plus VAT, and only the first of those moves by the hour.
So if spot halves, your bill does not. whenrun reports both numbers and labels which is
which:
saving 0.04 EUR (31.5% of this job's bill) 11% of its energy component
The flattering number is always the one labelled as the energy component. whenrun report
also prints your standing charge for the period, because no amount of load shifting changes
it and a savings figure without that context flatters the tool.
- Your actual tariff. It uses the numbers in your tariff file. If those are wrong, every
euro it reports is wrong by the same proportion.
doctorwarns while you are still on the shipped defaults. - Whether you are on a dynamic tariff at all. On a fixed tariff it will still tell you when power is cheap, and you will still be charged the same.
- Your own generation. Solar on your roof changes the answer completely and is not modelled.
- Grid fees that vary by time. Modelled as flat. Where a time-varying network tariff applies, the cheap window may not be exactly where this says.
- Whether your job really draws what you declared.
kwis your estimate, not a measurement. - It cannot pause a running command.
interruptibleshapes which slots are chosen and passes the list to your task inWHENRUN_SLOTS. Honouring the split is your script's job.
node mcp-server.mjsSix read-only tools: get_prices, cheapest_window, greenest_window, list_tasks,
plan_task, savings_report. Every result carries a coverage block with a freshness status
and a trustEmptyResult flag, so an agent can tell "prices are flat" from "I have not been
able to look since Tuesday".
| Source | Zone | Key needed | Resolution |
|---|---|---|---|
| Energy-Charts (Fraunhofer ISE) | DE-LU, AT, FR, NL and more | no | 15 min |
| aWATTar | DE, AT | no | 60 min |
Price data is CC BY 4.0 from Bundesnetzagentur | SMARD.de via Energy-Charts. doctor
cross-checks the two sources against each other and warns when they disagree by more than a
rounding error.
npm test # the gate: 128 tests, fully offline, no network
node test/live.mjs # checks the real APIs still answer in the expected shape
node test/live.mjs --update # and refreshes the captured fixturesOffline fixtures are captured from the live APIs rather than handwritten, so they test what the API does rather than what I remember it doing.
- docs/running-it.md — cron, systemd, Task Scheduler, and exactly when it will refuse to run something
- docs/roadmap.md — what works, what is missing, what is deliberately not being built
MIT. See LICENSE.