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_up (who is above the horizon, brightest first, each with a plain-language visibility field), 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).
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';
const url = 'https://www.cyclecalcs.com/v2/today'
+ '?lat=' + LAT + '&lon=' + LON + '&tz=' + encodeURIComponent(TZ);
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.');
const up = data.planets_up.filter(p => p.naked_eye);
lines.push(up.length
? 'Naked-eye planets above the horizon: '
+ up.map(p => p.name + ' (' + p.visibility.toLowerCase() + ')').join(', ') + '.'
: 'No naked-eye planet is above the horizon right now.');
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 at 00:13 UTC on 2026-08-13 with the London coordinates above, it printed:
$ node sky-report.mjs
Sky report for 2026-08-13
Moon: New Moon, 0.1% lit.
Naked-eye planets above the horizon: Saturn (morning sky, before dawn), Uranus (morning sky, before dawn).
Darkness: Good. No moonlight through the dark window.
Coming up: Partial lunar eclipse, 15 days away.
Note the honesty in the planets line: planets_up means above the horizon, which is not the same as visible right now. Each entry's visibility field ("morning sky, before dawn") says when that planet is actually worth looking for, so the bot passes the distinction along instead of flattening it.
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, not just 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: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 + ').');Run at 00:13 UTC on 2026-08-13, it printed: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.