Build a nightly sky report bot
One GET to /v2/today returns the whole sky for a place: the Moon, the planets, tonight's darkness and what is coming up. About forty lines of Node turn that into a report your Discord or Slack channel reads every evening.
Everything here runs on the free tier: no key, no signup, no header. The API overview has the wider picture; this page is just the build.
What you need
- Node 18 or newer (for built-in
fetch). No packages. - Somewhere to run a script once a day: GitHub Actions works and is free, so does any cron box or a Raspberry Pi on a shelf.
- Optional: a Discord webhook URL (Server Settings › Integrations › Webhooks) or a Slack incoming webhook. Without one, the report prints to the terminal.
Step 1: one call, the whole sky
Ask for today at your coordinates. tz= renders event times in your local zone; leave it off for UTC.
curl "https://www.cyclecalcs.com/v2/today?lat=51.5074&lon=-0.1278&tz=Europe/London"
The response's data object carries everything the report needs: moon.phase (name and illumination), planets_tonight (the planets above your horizon after civil dusk ends or before civil dawn begins, brightest first, each with a plain-language visibility field and the altitude it was decided on), night (when true darkness starts and ends, and a one-sentence verdict on moonlight), and next_events (upcoming eclipses, full moons and more, each with days_until). There is also planets_up, which is what is above the horizon at the moment of the call; run the call in daylight and that is a different list, so the report reads planets_tonight.
Step 2: the script
Save this as sky-report.mjs and set your own coordinates. It was run as written before being published here.
// sky-report.mjs: tonight's sky as one short report, from one GET.
// Set your own coordinates and timezone here.
const LAT = 51.5074, LON = -0.1278, TZ = 'Europe/London';
// SKY_AT pins the instant for a test run (any ISO time); unset means now.
const at = process.env.SKY_AT ? '&at=' + encodeURIComponent(process.env.SKY_AT) : '';
const url = 'https://www.cyclecalcs.com/v2/today'
+ '?lat=' + LAT + '&lon=' + LON + '&tz=' + encodeURIComponent(TZ) + at;
const res = await fetch(url, { headers: { accept: 'application/json' } });
if (!res.ok) throw new Error('API answered ' + res.status);
const { data } = await res.json();
const lines = ['Sky report for ' + data.local_date];
lines.push('Moon: ' + data.moon.phase.name + ', '
+ data.moon.phase.illumination_percent + '% lit.');
// planets_tonight is checked against YOUR horizon at dusk and dawn, so it
// never names a planet that sets before the sky is dark. planets_up is
// what is above the horizon at the moment the script runs, daylight included.
const tonight = data.planets_tonight.filter(p => p.naked_eye);
lines.push(tonight.length
? 'Planets tonight: ' + tonight.map(p => p.name + ' in ' + p.constellation.name
+ ' (' + p.visibility.toLowerCase() + ')').join(', ') + '.'
: 'No naked-eye planet is observable tonight.');
lines.push('Darkness: ' + data.night.verdict);
const next = data.next_events[0];
if (next) lines.push('Coming up: ' + next.label + ', '
+ Math.round(next.days_until) + ' days away.');
const report = lines.join('\n');
console.log(report);
// Optional: post it to a Discord channel via a webhook.
if (process.env.DISCORD_WEBHOOK_URL) {
await fetch(process.env.DISCORD_WEBHOOK_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content: report }),
});
}
Run with the London coordinates above at 17:00 UTC on 2026-10-08, which is 18:00 in London with the Sun still 3 degrees up (the same hour the workflow in step 4 fires), it printed:
$ SKY_AT=2026-10-08T17:00:00Z node sky-report.mjs
Sky report for 2026-10-08
Moon: Waning Crescent, 4.3% lit.
Planets tonight: Jupiter in Leo (morning sky, before dawn), Saturn in Cetus (up most of the night), Mars in Cancer (morning sky, before dawn), Uranus in Taurus (morning sky, before dawn).
Darkness: Good. No moonlight through the dark window.
Coming up: New Moon, 2 days away.
Note what the planets line does not say. At 18:00 that evening Venus and Mercury are both above the horizon in daylight, so both are in planets_up; but from London Venus sets at 18:10, thirteen minutes before the Sun, and Mercury at 18:50, before civil dusk ends at 18:55. Neither is in a dark sky at any point, so neither is on planets_tonight, and their planets_up entries say sets_in_twilight rather than the elongation-only "evening sky". Saturn, which had not risen at 18:00, is on the list because it is 4.8 degrees up when civil dusk ends and stays up all night. Every entry carries altitude_at_civil_dusk_deg and altitude_at_civil_dawn_deg, and planets_tonight_basis gives the two instants they were sampled at, so you can check any verdict against /v2/positions yourself. The same evening from Sydney, Venus is 18 degrees up at dusk and is listed. The check reads your horizon.
Step 3: point it at a channel
The script already posts to Discord when DISCORD_WEBHOOK_URL is set. Slack's incoming webhooks take {"text": report} instead of {"content": report}; everything else is identical. For anything fancier (Telegram, Matrix, an e-ink display on your desk), the report is just a string.
Step 4: schedule it
A GitHub Actions workflow runs it nightly for free. Commit the script to a repository, add the webhook URL as a repository secret, and add .github/workflows/sky-report.yml:
name: sky-report
on:
schedule:
- cron: "0 17 * * *" # every day at 17:00 UTC; adjust to your dusk
workflow_dispatch: {} # lets you run it by hand from the Actions tab
jobs:
post:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: node sky-report.mjs
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
One run a day is 0.02% of the free tier's 5,000 requests, so quota will never be your problem. If you ever build something chattier, the RateLimit headers on every response carry your remaining budget, and going over returns a 429 with a Retry-After you can honor.
Step 5: level up
Two natural upgrades, each still one request:
- Pick the observing night, which need not be tonight.
/v2/dark-windowranks up to 62 nights by genuinely dark, moonless minutes: dark means the Sun's center at or below -18 degrees (astronomical night), moonless means the Moon below the horizon by the same rise-and-set search the rest of the API uses, and the exact thresholds are published as data at/v2/conventions. This is the whole feature:Run at 00:13 UTC on 2026-08-13, it printed:const r = await fetch('https://www.cyclecalcs.com/v2/dark-window?lat=51.5074&lon=-0.1278&nights=14'); const { data } = await r.json(); const best = data.nights[data.best_night_index]; console.log('Best night: ' + best.label + ', ' + Math.round(best.usable_minutes) + ' moonless dark minutes (' + best.verdict + ').');Best night: Night of Wednesday 19 August 2026, 304 moonless dark minutes (excellent). - Add "worth looking tonight" verdicts per planet.
/v2/planet-boardreturns all eight planets with brightness, retrograde state and an explicit verdict, one call.
The report is yours: the numbers in an API response are computed astronomical facts, and the terms let you publish and build on them, commercially or not.
Stuck, or found an error in this guide? Contact us at info@cyclecalcs.com.