Drive Meteor Tally from your own code
Both metered steps - the watch plan and the observing report - are available over HTTP. A natural use is an astronomy club's page or a stargazing newsletter: your code builds the night (or a member's tally), sends it, and shows the answer.
The split first: the page does the arithmetic; the model writes the judgement. Sun and Moon positions, the radiant's altitude, the limiting-magnitude and rate estimates, the best window, the facing, the ZHR and its exact Poisson interval are all computed by meteorkit.js, which runs in node as well as in the browser. The model reads those numbers; it never redoes them.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"meteor-tally"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both tasks are metered and need a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Every run sends task, which routes the one system prompt to one of two contracts:
| task | What it returns | Verdicts |
|---|---|---|
plan | Watch plan, before the night: a summary; a window (start, end, why) inside the dark hours; a face (compass, altitude_deg, why); an expect range (low, high, note); 4–8 schedule steps {time, do}; kit; eyes-and-comfort tips; risks; 0–2 backup nights {date, why} inside the activity window. | go, short-window, skip-tonight |
report | Observing report, after the count: a summary; zhr (value, low, high quoted from the input totals, and a plain read); one periods entry per input period {id, read, flag} with flag ok, caution or drop; versus the expected activity; caveats; next-time improvements; a share text. | solid, usable, too-thin |
The declared input schema carries scalars only, so object- and array-valued fields travel JSON-encoded as strings (MeteorKit.toWire does this). task, night_date, shower and site are required.
Input fields
| Field | Type | Notes |
|---|---|---|
task | string | plan or report. |
night_date | string | Local date of the evening the watch starts; times before 12:00 are the next morning. |
shower | string (JSON) | {code, name, parent, peak, days_from_peak, active, active_window, zhr_peak_est, zhr_tonight_est, speed_kms, population_index, radiant_ra_deg, radiant_dec_deg} - approximate published values. |
site | string (JSON) | {name, lat, lon, utc_offset_h, sky, sky_lm_moonless}. |
notes | string | The observer's own words; over 1,400 characters keeps the first 1,000 and last 400 around a marker. |
hours_available | number | plan only: 1–12. |
facts | string (JSON) | plan only: {sunset, sunrise, dark_from, dark_to, astro_dark, moon:{phase, illum_pct, events[]}, hours:[{hour, dark, radiant_alt, moon_alt, lm_est, rate_est}], best_window, look, expected_night_est, checks[]}. |
periods | string (JSON) | report only: [{id, start, end, break_min, teff_h, shower_n, sporadic_n, lm, cloud_pct, radiant_alt, zhr, zhr_ci95, flags[]}]; zhr is null when the method does not apply. |
totals | string (JSON) | report only: {shower_n, sporadic_n, teff_h, zhr, zhr_ci95, n_used, expected_zhr_tonight_est}. |
brightness | string (JSON) | report only: {bright, medium, faint} counts. |
retry_note | string | Only on a reformat retry. |
The reply
The job's output.output is a string holding one JSON object. Parse it with MeteorKit.parseReply, then normalize, then check it with reconcile(reply, {analysis, tally}): it flags a window that starts before dark or runs into dawn, a window outside the best hours, an expected count far above the page's estimate, a facing that is not a compass point or differs from the page's pick, backup nights outside the activity window, a "go" on a night with a hard problem, a report ZHR outside the page's 95% interval, unknown or unread periods, and "solid" on fewer than 20 meteors.
Worked examples
Replies produced against the app's prompt (long lists shortened here; the full replies ship in example-result.js). The page replays each of them free from its example buttons. Inputs are shown with the hour table trimmed.
task: plan - Orionid peak from a rural valley, Colorado (go)
Input:
{
"task": "plan",
"night_date": "2026-10-21",
"shower": "{\"code\":\"ORI\",\"name\":\"Orionids\",\"parent\":\"comet 1P/Halley\",\"peak\":\"2026-10-21\",\"days_from_peak\":0,\"active\":true,\"active_window\":\"2026-10-02 to 2026-11-07\",\"zhr_peak_est\":20,\"zhr_tonight_est\":20,\"speed_kms\":66,\"population_index\":2.5,\"radiant_ra_deg\":95,\"radiant_dec_deg\":16}",
"site": "{\"name\":\"Wet Mountain Valley, Colorado\",\"lat\":38.1,\"lon\":-105.5,\"utc_offset_h\":-6,\"sky\":\"Rural\",\"sky_lm_moonless\":6}",
"notes": "Driving up from Denver with a friend. We can be out from about 2 am until dawn. Forecast is clear and near freezing.",
"hours_available": 4,
"facts": "{\"sunset\":\"18:15\",\"sunrise\":\"07:17\",\"dark_from\":\"19:13\",\"dark_to\":\"06:20\",\"astro_dark\":true,\"moon\":{\"phase\":\"waxing gibbous\",\"illum_pct\":83,\"events\":[\"sets 03:23\"]},\"hours\":[{\"hour\":\"19:00\",\"dark\":true,\"radiant_alt\":-28,\"moon_alt\":35,\"lm_est\":4.3,\"rate_est\":0},{\"hour\":\"20:00\",\"dark\":true,\"radiant_alt\":-20,\"moon_alt\":41,\"lm_est\":4.7,\"rate_est\":0},{\"hour\":\"21:00\",\"dark\":true,\"radiant_alt\":-10,\"moon_alt\":43,\"lm_est\":4.7,\"rate_est\":0},\"...\"],\"best_window\":{\"start\":\"03:00\",\"end\":\"06:00\",\"peak_hour\":\"05:00\",\"peak_rate_est\":11.7,\"expected_est\":34},\"look\":{\"compass\":\"WNW\",\"altitude_deg\":55,\"from_radiant_deg\":45,\"from_moon_deg\":null},\"expected_night_est\":44,\"checks\":[{\"level\":\"info\",\"code\":\"peak-night\",\"text\":\"Peak night of the Orionids (ZHR about 20, an estimate).\"},{\"level\":\"info\",\"code\":\"expect\",\"text\":\"Best window 03:00-06:00: about 34 shower meteors for one observer (an estimate), peaking near 11.7/hr around 05:00.\"},\"...\"]}"
}
Reply:
{
"task": "plan",
"title": "Orionids peak night - go for it after 2 AM",
"verdict": "go",
"summary": "Peak night for the Orionids, and from Wet Mountain Valley's dark rural sky this is a strong watch. The radiant climbs through Orion from 2 AM onward and the bright gibbous moon sets by 3:23 AM, so the hourly rate keeps rising right into dawn. Near-freezing temps mean this is as much about staying warm as staying awake.",
"window": {
"start": "02:00",
"end": "06:00",
"why": "The best 4-hour stretch inside your available time: radiant altitude climbs from 47 to 68 degrees and the bright moon has set by 03:23, so the rate keeps rising through dawn."
},
"face": {
"compass": "WNW",
"altitude_deg": 55,
"why": "The page's look direction keeps the radiant about 45 degrees off to your side so streaks and trains show up well across your field of view."
},
"expect": {
"low": 32,
"high": 42,
"note": "Summing the page's hourly rate estimates for 02:00-06:00 gives about 39 shower meteors for one observer; a few sporadics (meteors not from this shower) could add a little more. This is a rough estimate, not a promise."
},
"schedule": [
{
"time": "01:15",
"do": "Leave with time to spare on the rural roads in the dark."
},
{
"time": "01:45",
"do": "Arrive, set up facing WNW, and start a 20-minute dark adaptation - phones away."
},
"..."
],
"kit": [
"insulated layers and a hat - it's near freezing",
"hot drink in a thermos",
"..."
],
"eyes": [
"give yourself a full 20 minutes of darkness before counting - no phone screens",
"use only red light for notes or maps",
"..."
],
"risks": [
"near-freezing temperatures - watch for numb hands and feet",
"dark rural roads driving in - go slowly and watch for wildlife",
"fatigue on the drive back to Denver after being up all night"
],
"backup": [
{
"date": "2026-10-22",
"why": "Still essentially peak-level activity the next night, in case clouds move in; the moon will be slightly fuller, which isn't factored in here."
}
]
}
task: report - Orionid peak from a rural valley, Colorado (solid)
Input:
{
"task": "report",
"night_date": "2026-10-21",
"shower": "{\"code\":\"ORI\",\"name\":\"Orionids\",\"parent\":\"comet 1P/Halley\",\"peak\":\"2026-10-21\",\"days_from_peak\":0,\"active\":true,\"active_window\":\"2026-10-02 to 2026-11-07\",\"zhr_peak_est\":20,\"zhr_tonight_est\":20,\"speed_kms\":66,\"population_index\":2.5,\"radiant_ra_deg\":95,\"radiant_dec_deg\":16}",
"site": "{\"name\":\"Wet Mountain Valley, Colorado\",\"lat\":38.1,\"lon\":-105.5,\"utc_offset_h\":-6,\"sky\":\"Rural\",\"sky_lm_moonless\":6}",
"notes": "Driving up from Denver with a friend. We can be out from about 2 am until dawn. Forecast is clear and near freezing.",
"periods": "[{\"id\":\"P1\",\"start\":\"03:00\",\"end\":\"04:00\",\"break_min\":0,\"teff_h\":1,\"shower_n\":10,\"sporadic_n\":4,\"lm\":6,\"cloud_pct\":0,\"radiant_alt\":57,\"zhr\":18.8,\"zhr_ci95\":[9,34.5],\"flags\":[]},{\"id\":\"P2\",\"start\":\"04:00\",\"end\":\"05:00\",\"break_min\":5,\"teff_h\":0.92,\"shower_n\":13,\"sporadic_n\":5,\"lm\":6.1,\"cloud_pct\":0,\"radiant_alt\":65,\"zhr\":22.5,\"zhr_ci95\":[12,38.5],\"flags\":[]},{\"id\":\"P3\",\"start\":\"05:00\",\"end\":\"05:50\",\"break_min\":0,\"teff_h\":0.83,\"shower_n\":9,\"sporadic_n\":2,\"lm\":5.9,\"cloud_pct\":10,\"radiant_alt\":68,\"zhr\":22.5,\"zhr_ci95\":[10.3,42.6],\"flags\":[]}]",
"totals": "{\"shower_n\":32,\"sporadic_n\":11,\"teff_h\":2.75,\"zhr\":21.2,\"zhr_ci95\":[14.5,29.9],\"n_used\":32,\"expected_zhr_tonight_est\":20}",
"brightness": "{\"bright\":6,\"medium\":14,\"faint\":12}"
}
Reply:
{
"task": "report",
"title": "A strong Orionids night, well earned",
"verdict": "solid",
"summary": "You hit peak night under genuinely dark, rural skies - limiting magnitudes around 6 in all three periods - and stayed out through the best pre-dawn hours as the radiant climbed from 57 to 68 degrees. With 32 shower meteors over 2 hours 45 minutes of effective time, the overall ZHR comes out to about 21, a solid result that lines up well with the roughly 20 expected for the Orionids tonight.",
"zhr": {
"value": 21.2,
"low": 14.5,
"high": 29.9,
"read": "The overall ZHR is 21.2, with a 95% range of 14.5 to 29.9. That spread comes from Poisson counting statistics: with 32 meteors behind it, the relative uncertainty is roughly 1 over the square root of 32, about 18 percent, so the interval is already fairly narrow and would tighten further with more meteors."
},
"periods": [
{
"id": "P1",
"read": "10 shower meteors in a full 60 minutes with the radiant already 57 degrees up and a clean, cloud-free sky - a solid hour on its own, ZHR 18.8 (9 to 34.5).",
"flag": "ok"
},
{
"id": "P2",
"read": "The best hour of the night: 13 shower meteors in 55 effective minutes as the radiant climbed to 65 degrees, pushing the hourly ZHR to about 22.5.",
"flag": "ok"
},
{
"id": "P3",
"read": "Light cloud crept in (10 percent) in the final, shorter stretch, but it still turned up 9 shower meteors in 50 minutes before dawn, holding the ZHR near 22.5.",
"flag": "ok"
}
],
"versus": "The night's ZHR of 21.2 sits right at the roughly 20 the Orionids were expected to produce at peak, and 20 falls comfortably inside the measured interval - a clean consistent-with-forecast result, no explaining away needed.",
"caveats": [
"Individual hours only had 9 to 13 shower meteors each, so the hour-by-hour swing (18.8 to 22.5) is mostly counting noise, not a real change in activity.",
"A 5-minute break before P2 and 10 percent cloud in P3 trimmed a little effective time from those hours; that is already reflected in their teff_h.",
"..."
],
"next_time": [
"You were on site by about 2:00 but counting did not start until 3:00 - starting the formal tally as soon as you are dark-adapted would add close to another hour of data.",
"Keep breaks between periods as short as you can; even 5 minutes trims effective time and widens the interval a touch.",
"..."
],
"share": "Orionids watch from Wet Mountain Valley, Colorado on the night of Oct 21, 2026 - a rural, dark site. Over 2 hours 45 minutes effective time (3:00 to 5:50 am) we tallied 32 shower meteors plus 11 sporadics, for a ZHR of 21.2 (95% range 14.5-29.9) - right on the expected peak rate of about 20."
}
task: plan - Perseid peak from a suburban yard, two hours max (short-window)
Input:
{
"task": "plan",
"night_date": "2026-08-12",
"shower": "{\"code\":\"PER\",\"name\":\"Perseids\",\"parent\":\"comet 109P/Swift-Tuttle\",\"peak\":\"2026-08-12\",\"days_from_peak\":0,\"active\":true,\"active_window\":\"2026-07-17 to 2026-08-24\",\"zhr_peak_est\":100,\"zhr_tonight_est\":100,\"speed_kms\":59,\"population_index\":2.2,\"radiant_ra_deg\":48,\"radiant_dec_deg\":58}",
"site": "{\"name\":\"Back yard north of New York\",\"lat\":40.95,\"lon\":-73.8,\"utc_offset_h\":-4,\"sky\":\"Suburb or town edge\",\"sky_lm_moonless\":5}",
"notes": "Back yard, the neighbours leave porch lights on. Work the next day, so two hours at most, 10 pm to midnight. Kids aged 9 and 12 want to see at least one.",
"hours_available": 2,
"facts": "{\"sunset\":\"19:57\",\"sunrise\":\"06:03\",\"dark_from\":\"21:03\",\"dark_to\":\"04:57\",\"astro_dark\":true,\"moon\":{\"phase\":\"new\",\"illum_pct\":0,\"events\":[\"sets 20:01\"]},\"hours\":[{\"hour\":\"20:00\",\"dark\":false,\"radiant_alt\":12,\"moon_alt\":-5,\"lm_est\":2.5,\"rate_est\":0},{\"hour\":\"21:00\",\"dark\":true,\"radiant_alt\":15,\"moon_alt\":-15,\"lm_est\":4.7,\"rate_est\":6.3},{\"hour\":\"22:00\",\"dark\":true,\"radiant_alt\":20,\"moon_alt\":-24,\"lm_est\":5,\"rate_est\":10.4},\"...\"],\"best_window\":{\"start\":\"00:00\",\"end\":\"04:57\",\"peak_hour\":\"03:00\",\"peak_rate_est\":25.3,\"expected_est\":106},\"look\":{\"compass\":\"NW\",\"altitude_deg\":55,\"from_radiant_deg\":47,\"from_moon_deg\":null},\"expected_night_est\":136,\"checks\":[{\"level\":\"info\",\"code\":\"peak-night\",\"text\":\"Peak night of the Perseids (ZHR about 100, an estimate).\"},{\"level\":\"info\",\"code\":\"expect\",\"text\":\"Best window 00:00-04:57: about 106 shower meteors for one observer (an estimate), peaking near 25.3/hr around 03:00.\"}]}"
}
Reply:
{
"task": "plan",
"title": "A solid hour or two before the Perseids really get going",
"verdict": "short-window",
"summary": "Peak night for the Perseids, but your 10 PM to midnight window catches the shower before it really ramps up - the radiant is only 20 to 26 degrees high then, well short of its best around 3 AM. Even so, with a new moon and clear dark skies from 9 PM, you should still see a steady trickle, more than enough for the kids to catch several.",
"window": {
"start": "22:00",
"end": "00:00",
"why": "This matches the two hours you have before bed and work tomorrow; the rate is already climbing nicely by 10 PM even though the real peak comes hours after you'll be inside."
},
"face": {
"compass": "NW",
"altitude_deg": 55,
"why": "This is the page's recommended direction for tonight's radiant geometry; if a porch light sits that way, just turn your chairs slightly and shield your eyes with a cap brim."
},
"expect": {
"low": 18,
"high": 28,
"note": "The page's hourly rates for 22:00-00:00 add up to about 24 shower meteors for one observer; a few sporadics could nudge that higher. It's an estimate, but the kids should see at least one within the first 15-20 minutes on a night like this."
},
"schedule": [
{
"time": "21:40",
"do": "Head outside, get comfortable, and start a 20-minute no-screens dark adaptation."
},
{
"time": "22:00",
"do": "Start watching, facing NW, reclined so nobody has to crane their neck."
},
"..."
],
"kit": [
"reclining lawn chairs or blankets on the grass",
"warm layers for the overnight temperature drop",
"..."
],
"eyes": [
"20 minutes with no screens before you start counting",
"turn away from the porch-light glare if it bothers anyone",
"..."
],
"risks": [
"watch for trip hazards like yard furniture or hoses in the dark",
"keep an eye on the clock - it's a school and work night for everyone"
],
"backup": [
{
"date": "2026-08-13",
"why": "Activity is still near its peak the following night and the moon stays out of the way; not computed here, but worth a repeat if clouds interfere tonight."
}
]
}
task: report - Perseid peak from a suburban yard, two hours max (usable)
Input:
{
"task": "report",
"night_date": "2026-08-12",
"shower": "{\"code\":\"PER\",\"name\":\"Perseids\",\"parent\":\"comet 109P/Swift-Tuttle\",\"peak\":\"2026-08-12\",\"days_from_peak\":0,\"active\":true,\"active_window\":\"2026-07-17 to 2026-08-24\",\"zhr_peak_est\":100,\"zhr_tonight_est\":100,\"speed_kms\":59,\"population_index\":2.2,\"radiant_ra_deg\":48,\"radiant_dec_deg\":58}",
"site": "{\"name\":\"Back yard north of New York\",\"lat\":40.95,\"lon\":-73.8,\"utc_offset_h\":-4,\"sky\":\"Suburb or town edge\",\"sky_lm_moonless\":5}",
"notes": "Back yard, the neighbours leave porch lights on. Work the next day, so two hours at most, 10 pm to midnight. Kids aged 9 and 12 want to see at least one.",
"periods": "[{\"id\":\"P1\",\"start\":\"22:00\",\"end\":\"23:00\",\"break_min\":0,\"teff_h\":1,\"shower_n\":6,\"sporadic_n\":3,\"lm\":5,\"cloud_pct\":20,\"radiant_alt\":20,\"zhr\":72.1,\"zhr_ci95\":[26.5,157],\"flags\":[\"radiant low (under 20\\u00b0)\"]},{\"id\":\"P2\",\"start\":\"23:00\",\"end\":\"00:00\",\"break_min\":10,\"teff_h\":0.83,\"shower_n\":9,\"sporadic_n\":2,\"lm\":5.1,\"cloud_pct\":30,\"radiant_alt\":26,\"zhr\":107.2,\"zhr_ci95\":[49,203.5],\"flags\":[\"over 20% cloud\"]}]",
"totals": "{\"shower_n\":15,\"sporadic_n\":5,\"teff_h\":1.83,\"zhr\":89.7,\"zhr_ci95\":[50.2,148],\"n_used\":15,\"expected_zhr_tonight_est\":100}",
"brightness": "{\"bright\":4,\"medium\":7,\"faint\":4}"
}
Reply:
{
"task": "report",
"title": "A real Perseid count, with honest caveats",
"verdict": "usable",
"summary": "Fifteen shower meteors in under two hours from a porch-lit suburban yard, with the radiant only 20 to 26 degrees up, still produces a usable ZHR of about 90 - a real result, but built on a modest count with the sky and radiant working against you. It lines up with the roughly 100 expected on peak night, just with a wide margin either way.",
"zhr": {
"value": 89.7,
"low": 50.2,
"high": 148,
"read": "The overall ZHR is 89.7, with a 95% range of 50.2 to 148 - a wide band because it rests on just 15 shower meteors. Poisson counting means the relative uncertainty scales with 1 over the square root of 15, around 26 percent, and that spread is exactly what shows up between 50 and 148."
},
"periods": [
{
"id": "P1",
"read": "6 shower meteors with the radiant only 20 degrees up, low enough that the page flagged it - a low radiant inflates both the correction and its uncertainty.",
"flag": "caution"
},
{
"id": "P2",
"read": "9 shower meteors as the radiant rose a little to 26 degrees, but cloud reached 30 percent, trimming effective time and adding its own noise.",
"flag": "caution"
}
],
"versus": "At 89.7 the result sits a bit under the roughly 100 expected for peak night, but 100 still falls inside the measured interval, so this reads as consistent with expectations once you allow for the small sample, the low radiant, and the cloud.",
"caveats": [
"Only 15 shower meteors total across two short hours - the swing from 72 to 107 hour to hour is mostly noise, not a real change in the shower.",
"The radiant was low the whole session (20 to 26 degrees), which the page's flag and the correction factor both reflect; low-radiant ZHRs run noisier than high-radiant ones.",
"..."
],
"next_time": [
"A later or longer window on another peak night would catch the radiant higher up, with a less extreme correction.",
"A site even a little darker than the porch-lit yard would raise the limiting magnitude and pick up fainter meteors.",
"..."
],
"share": "Perseids watch from a suburban yard north of New York City on the night of Aug 12, 2026 - porch lights and patchy cloud. Over 1 hour 50 minutes effective time (10 pm to midnight) we tallied 15 shower meteors plus 5 sporadics, for a ZHR of about 90 (95% range 50-148), broadly in line with the roughly 100 expected at peak."
}
task: plan - Leonids from a London rooftop (skip-tonight)
Input:
{
"task": "plan",
"night_date": "2026-11-17",
"shower": "{\"code\":\"LEO\",\"name\":\"Leonids\",\"parent\":\"comet 55P/Tempel-Tuttle\",\"peak\":\"2026-11-17\",\"days_from_peak\":0,\"active\":true,\"active_window\":\"2026-11-06 to 2026-11-30\",\"zhr_peak_est\":15,\"zhr_tonight_est\":15,\"speed_kms\":71,\"population_index\":2.5,\"radiant_ra_deg\":152,\"radiant_dec_deg\":22}",
"site": "{\"name\":\"Flat roof in east London\",\"lat\":51.54,\"lon\":-0.06,\"utc_offset_h\":0,\"sky\":\"City centre\",\"sky_lm_moonless\":4}",
"notes": "Flat roof, can go up after midnight for a couple of hours. Light pollution is bad - I can see maybe 15 stars.",
"hours_available": 2,
"facts": "{\"sunset\":\"16:08\",\"sunrise\":\"07:23\",\"dark_from\":\"17:26\",\"dark_to\":\"06:04\",\"astro_dark\":true,\"moon\":{\"phase\":\"first quarter\",\"illum_pct\":55,\"events\":[\"sets 23:25\"]},\"hours\":[{\"hour\":\"17:00\",\"dark\":true,\"radiant_alt\":-16,\"moon_alt\":25,\"lm_est\":2.5,\"rate_est\":0},{\"hour\":\"18:00\",\"dark\":true,\"radiant_alt\":-16,\"moon_alt\":26,\"lm_est\":3.4,\"rate_est\":0},{\"hour\":\"19:00\",\"dark\":true,\"radiant_alt\":-15,\"moon_alt\":24,\"lm_est\":3.5,\"rate_est\":0},\"...\"],\"best_window\":{\"start\":\"02:00\",\"end\":\"06:00\",\"peak_hour\":\"04:00\",\"peak_rate_est\":1.2,\"expected_est\":4},\"look\":{\"compass\":\"NE\",\"altitude_deg\":55,\"from_radiant_deg\":48,\"from_moon_deg\":null},\"expected_night_est\":6,\"checks\":[{\"level\":\"info\",\"code\":\"peak-night\",\"text\":\"Peak night of the Leonids (ZHR about 15, an estimate).\"},{\"level\":\"info\",\"code\":\"expect\",\"text\":\"Best window 02:00-06:00: about 4 shower meteors for one observer (an estimate), peaking near 1.2/hr around 04:00.\"},\"...\"]}"
}
Reply:
{
"task": "plan",
"title": "Leonids: a city roof won't show much tonight",
"verdict": "skip-tonight",
"summary": "The Leonids peak tonight, but from a light-polluted central London roof where you can only make out about 15 stars, the page's own numbers put the expected count at roughly 2 meteors an hour even in the best slot - well under what makes a watch worthwhile. A first-quarter moon also washes out the sky until it sets at 23:25. This one isn't worth losing sleep over from this site.",
"window": {
"start": "04:00",
"end": "06:00",
"why": "If you go up anyway, this is the best 2-hour slot the page's numbers support - radiant highest, moon long since down - though rates still barely top 1 per hour."
},
"face": {
"compass": "NE",
"altitude_deg": 55,
"why": "This is the page's recommended direction toward the radiant for whatever faint streaks do appear."
},
"expect": {
"low": 1,
"high": 3,
"note": "Summing the page's hourly rates for 04:00-06:00 gives roughly 2 shower meteors for one observer - city glow and moonlight both cut hard into what this modest shower (ZHR about 15) offers. This is a rough estimate and you could easily see none."
},
"schedule": [
{
"time": "00:00",
"do": "Consider skipping tonight's watch altogether and picking a darker night or site instead (see backup below)."
},
{
"time": "03:40",
"do": "If you still want to try, head up and give your eyes 15-20 minutes to adjust, though city glow limits how dark-adapted you can get."
},
"..."
],
"kit": [
"warm coat, hat and gloves - a November roof is cold",
"something to sit or lean on",
"..."
],
"eyes": [
"15-20 minutes to adapt, though a city sky limits how much it helps",
"avoid phone and street-light glare while watching",
"..."
],
"risks": [
"stay well clear of the roof edge, especially in the dark and cold",
"watch for frost making the roof surface slippery"
],
"backup": [
{
"date": "2026-11-20",
"why": "Still inside the Leonids' active window with a less intrusive moon; the page's own note suggests a short trip to darker sky would help far more than waiting for a different night."
}
]
}
task: report - Leonids from a London rooftop (too-thin)
Input:
{
"task": "report",
"night_date": "2026-11-17",
"shower": "{\"code\":\"LEO\",\"name\":\"Leonids\",\"parent\":\"comet 55P/Tempel-Tuttle\",\"peak\":\"2026-11-17\",\"days_from_peak\":0,\"active\":true,\"active_window\":\"2026-11-06 to 2026-11-30\",\"zhr_peak_est\":15,\"zhr_tonight_est\":15,\"speed_kms\":71,\"population_index\":2.5,\"radiant_ra_deg\":152,\"radiant_dec_deg\":22}",
"site": "{\"name\":\"Flat roof in east London\",\"lat\":51.54,\"lon\":-0.06,\"utc_offset_h\":0,\"sky\":\"City centre\",\"sky_lm_moonless\":4}",
"notes": "Flat roof, can go up after midnight for a couple of hours. Light pollution is bad - I can see maybe 15 stars.",
"periods": "[{\"id\":\"P1\",\"start\":\"01:00\",\"end\":\"02:00\",\"break_min\":0,\"teff_h\":1,\"shower_n\":2,\"sporadic_n\":1,\"lm\":4,\"cloud_pct\":30,\"radiant_alt\":28,\"zhr\":60.4,\"zhr_ci95\":[7.3,218.3],\"flags\":[\"bright sky (limiting magnitude under 5)\",\"over 20% cloud\"]},{\"id\":\"P2\",\"start\":\"02:00\",\"end\":\"02:40\",\"break_min\":0,\"teff_h\":0.67,\"shower_n\":1,\"sporadic_n\":1,\"lm\":4.1,\"cloud_pct\":40,\"radiant_alt\":36,\"zhr\":38.7,\"zhr_ci95\":[1,215.6],\"flags\":[\"bright sky (limiting magnitude under 5)\",\"over 20% cloud\"]}]",
"totals": "{\"shower_n\":3,\"sporadic_n\":2,\"teff_h\":1.67,\"zhr\":50.9,\"zhr_ci95\":[10.5,148.8],\"n_used\":3,\"expected_zhr_tonight_est\":15}",
"brightness": "{\"bright\":2,\"medium\":1,\"faint\":0}"
}
Reply:
{
"task": "report",
"title": "Too few meteors for a real rate, but you saw some",
"verdict": "too-thin",
"summary": "Three shower meteors in 1 hour 40 minutes from a bright city rooftop is not enough to pin down a rate - the page's own ZHR of 51 carries a 95% range stretching from about 10 to 149, which is too wide to mean much on its own. Still, catching 3 Leonids plus 2 sporadics through that much skyglow and cloud is a real, countable result.",
"zhr": {
"value": 50.9,
"low": 10.5,
"high": 148.8,
"read": "The overall ZHR works out to 50.9, but with only 3 shower meteors behind it the 95% interval runs from 10.5 to 148.8 - anywhere from a quiet night to a strong one. With counts this small, Poisson uncertainty scales as 1 over the square root of 3, over 55 percent, which is why the interval is nearly as wide as the number itself."
},
"periods": [
{
"id": "P1",
"read": "2 shower meteors under a bright sky (limiting magnitude 4) with 30 percent cloud - too little on its own to say much beyond 'some activity was there'.",
"flag": "caution"
},
{
"id": "P2",
"read": "A shortened 40-minute stretch with just 1 shower meteor as cloud thickened to 40 percent and the sky stayed bright - barely enough to register.",
"flag": "caution"
}
],
"versus": "The measured ZHR of 50.9 technically brackets the roughly 15 expected tonight, since 15 falls within the wide 10.5-148.8 interval, but with only 3 meteors counted that overlap says more about how wide the interval is than about how active the shower actually was.",
"caveats": [
"Only 3 shower meteors total - a ZHR built on a sample this small is a placeholder for 'not enough data' rather than a real rate.",
"Both periods carried the same two flags: a bright sky (limiting magnitude around 4, matching the roughly 15 stars visible you noted) and over 20 percent cloud, which both hide meteors and inflate the correction.",
"..."
],
"next_time": [
"A darker site, even a short trip out of central London, would lift the limiting magnitude well above 4 and let far more meteors register.",
"If the roof is the only option, a longer session would help average out the Poisson noise even with the sky staying bright.",
"..."
],
"share": "Leonids watch from a flat roof in east London on the night of Nov 17, 2026 - a bright city sky with patchy cloud. Over 1 hour 40 minutes effective time (1:00 to 2:40 am) we tallied 3 shower meteors plus 2 sporadics; the resulting ZHR estimate (about 51, 95% range 10-149) is too uncertain to read as a real rate, though it confirms some Leonid activity got through."
}
Build the input yourself
Load the script in node (global.window = {}; require("./meteorkit.js")), then:
const K = MeteorKit;
const state = { shower: "ORI", date: "2026-10-21", lat: 38.1, lon: -105.5, offset: -6, sky: "rural", hours: 4, site: "valley" };
const periods = [ { start: "03:00", end: "04:00", brk: 0, n: 10, s: 4, lm: 6.0, cloud: 0 } ];
const analysis = K.analyse(state), tally = K.tally(state, periods);
const { input } = K.buildInput("plan", { state, analysis, notes: "clear, near freezing" }); // or "report" with { tally, mags }
const wire = K.toWire(input); // POST this to /estimate and /run
// later: const reply = K.normalize(K.parseReply(job.output.output));
// K.reconcile(reply, { analysis, tally }) -> notes
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://meteor-tally.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://meteor-tally.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// meteor-tally.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://meteor-tally.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://meteor-tally.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// CueScroll.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class CueScroll {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://meteor-tally.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://meteor-tally.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://meteor-tally.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://meteor-tally.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"meteor-tally"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "meteor-tally"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "meteor-tally" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "meteor-tally"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "meteor-tally"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "meteor-tally" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "meteor-tally"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "meteor-tally" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "plan", "night_date": "2026-10-21", "shower": "{...}", "site": "{...}", "facts": "{...}"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "plan", "night_date": "2026-10-21", "shower": "{...}", "site": "{...}", "facts": "{...}"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task, night_date, shower, site, notes, hours_available, facts} or {..., periods, totals, brightness}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses meteor-tally:<task>:<inputHash>:a<attempt>, and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="meteor-tally:plan:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "meteor-tally:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `meteor-tally:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "meteor-tally:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "meteor-tally:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "meteor-tally:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["verdict"]].inspect
$key = "meteor-tally:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["verdict"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"meteor-tally:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: meteor-tally:plan:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "plan", "night_date": "2026-10-21", "shower": "{...}", "site": "{...}", "facts": "{...}"}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "meteor-tally:plan:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `meteor-tally:plan:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "meteor-tally:plan:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "meteor-tally:plan:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "meteor-tally:plan:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: meteor-tally:plan:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"meteor-tally:plan:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);