← Observing Desk / API
Tokens

Drive Observing Desk from your own code

Everything the web page does is available over HTTP: send the night's computed ephemeris for one site and one target list, get the same ordered observing plan back — or send your own draft run sheet and get it judged row by row. The natural use is a script that re-plans a standing target list every afternoon for tonight's date and mails the observer a run sheet, or a check that runs over a shared run sheet before the dome opens and refuses a session whose rows sit outside the dark window.

One thing to be clear about before the first call: the model never computes astronomy. Twilight, the dark window, the Moon and every target's rise, transit, set, window, best altitude, airmass and Moon separation are computed by the caller and sent as facts. The model's job is judgement over those facts — the ordering, the drops, the risks, the verdict on a draft. See computing the facts yourself below; the same engine the web page uses ships as plain scripts you can load in node.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

The token is minted for this app (the guest endpoint takes {"slug":"observing-desk"} in its body), so no slug header is needed afterwards — send your token as Authorization: Bearer … on every call.

The input object IS the request body. There is no {"input": …} wrapper. A body that wraps the object returns a cheerful 200 and the lane never sees your task, night or targets — post the object itself, exactly as the worked examples below show it.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run. Check that the token was minted for observing-desk and that you signed in for a run.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422The input object is missing a required field — targets and night are the usual ones — or a field is the wrong type. A body that is not valid JSON at all comes back as a 400.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure, reported as server_error on a plain 500. Retry with the SAME Idempotency-Key so you are not billed twice.

1. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. Nothing on that page needs a developer tool — it reads the same storage the app itself uses and prints the token for you.

A guest token can call /me and /estimate. Planning a night and checking a draft are both metered, so they need a personal token from signing in.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://observing-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; planning or checking a night needs a personal token
# from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" -d '{"slug":"observing-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}

2. A tiny client

One helper that adds the headers, unwraps data and raises on error.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="observing-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://observing-desk.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

3. Check the session and the balance

GET /me tells you whether the token is a guest or a person, and what the balance is. subject_type is guest or user — a guest can price a run but cannot start one — and credits is the wallet balance in credits. Compare it against min_credits from the next step before you run, so a shortfall surfaces as your own clear message rather than a 402.

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run — free

The input object is exactly what the app's own form submits. The first field to get right is task, because it selects the lane and the two lanes have different contracts. The rest of the object is the same for both, except that check also carries the observer's draft.

taskwhat it doesextra inputthe shape you get back
planOrders the night: which target when, what to drop, where the gaps are, what to watch for.noneschedule, dropped, gaps, risks, checklist, night_summary; verdict is ready, thin or rework.
checkJudges the observer's own draft run sheet row by row against the same computed night, then rewrites it.draft_text and draft_rowsfindings (one per draft row), revised, notes; verdict is sound, fixable or rework.

If task is missing or unrecognised, the lane is inferred from the fields present — a draft_rows array means check — and the lane actually used comes back in the output's lane field. Read lane rather than assuming; the two contracts are never blended.

The app declares an input schema, so /estimate and /run return input_checked: true and a warnings array. task is required; preferences, draft_text and retry_note are declared strings. The schema can only describe flat values, so the object and array fields (site, night, constraints, targets, clipped, draft_rows) come back as unknown field warnings on every correct call. Those are expected and harmless. A missing required field 'task' warning, or an unknown field 'input' warning, means the body is wrong: send the input object itself, not {"input": {...}}. Warnings never stop a run, so check them before you pay.

The fields both lanes take

fieldtypemeaning
taskstring, requiredplan or check.
siteobject, required{name, lat, lon, height, utc_offset}. Latitude and longitude in degrees, north and east positive; height in metres; utc_offset in hours, the site's clock offset for that night.
nightobject, requiredThe computed night: date, utc_offset, sunset, sunrise, civil_dusk, civil_dawn, nautical_dusk, nautical_dawn, astronomical_dusk, astronomical_dawn, dark_start, dark_end, dark_kind, dark_hours, lst_midnight, and moon {illumination_pct, phase, rise, set, up_in_dark_pct}. Every time is "HH:MM" on the site's local clock.
constraintsobject, required{min_alt, min_moon_sep, session_start, session_end}. The altitude floor and Moon-separation floor in degrees; the two session bounds are "HH:MM" or "" for "the whole dark window".
preferencesstringFree text from the observer — mount, camera, what they care about, when they want to stop. A German equatorial mount here makes the reply call out meridian flips. May be empty.
targetsobject[], requiredOne entry per target, each with name, ra, dec, priority (1 is highest), minutes_wanted, note, rise, transit, transit_alt, set, window_start, window_end, window_minutes, best_alt, best_at, best_airmass, moon_sep_midwindow, min_moon_sep, status and flags. status is observable, marginal or unobservable; an unobservable target is never scheduled and comes back in dropped with the engine's flag as the reason. Null window fields are correct for a target that never clears the floor. Each target also carries alt_by_hour, described below — the field the reply's altitudes are read out of.
clippedobject, optional{sent, total, dropped_names} when you trimmed a long list before sending it. Say so and the reply says so too.
draft_textstring, check onlyThe observer's draft run sheet exactly as pasted, newline-separated.
draft_rowsobject[], check onlyYour reading of that draft, one row per line: line, raw, start, end, minutes, target (the matched target name, or null when no target in the list matches) and facts {alt_start, alt_end, airmass_start, moon_sep, moon_up, in_dark, in_window, flags}. facts.flags is your own verdict on the slot — "starts before astronomical dusk (18:40)", "below the 30 degree floor at 04:30 (15 degrees)", "target not in the list", "overlaps the previous row". An empty flags means you found nothing wrong.
retry_notestring, optionalSend only on a retry, when a previous reply failed to parse or came back truncated. The instruction is obeyed exactly.

alt_by_hour, the field the altitudes come out of

Every target carries alt_by_hour: a plain object mapping each local half hour to that target's altitude in whole degrees, {"21:00": 58, "21:30": 63, "22:00": 69, …}. It is not decoration. It is the only source the reply is allowed to use for a slot's alt_start and alt_end — the value at that half hour, or the linear interpolation between the two neighbouring entries when the slot starts between them. From that, airmass_start is 1 / sin(alt_start) to two decimals, with 1.00 used above 85 degrees, and moon_sep is copied straight from the target's moon_sep_midwindow.

Two consequences for a caller. Cover the whole span you want scheduled: a slot that starts at 21:10 needs both "21:00" and "21:30" present, and a target whose map runs out mid-window cannot be placed there. And because the derivation is arithmetic, you can recompute every altitude and airmass in the reply from your own input and assert they match — the check in section 7 does exactly that, and it is the cheapest lie detector on this page. An unobservable target takes an empty map, {}.

Computing the facts yourself

night, targets and — for the check lane — draft_rows are facts, not requests. The browser computes them before the run with an engine verified against astropy, and an API caller has to compute them too: the model is told never to contradict a computed time, altitude, airmass or separation, and it has no way to derive one. Send a wrong sunset and you get a confident plan built on a wrong sunset.

You do not have to write that engine. The three scripts the web page loads are plain, dependency-free scripts served from this app, and they run unchanged in node:

scriptwhat it gives you
/sky.jsThe ephemeris: sunset and sunrise, the three twilights, the dark window and dark_kind, local sidereal time at midnight, the Moon's illumination, phase, rise and set, and per target the rise, transit, transit altitude, set, the window above min_alt during darkness, the best altitude and airmass and the Moon separation at mid-window, and the alt_by_hour table at every local half hour. Altitudes are geometric — no refraction.
/parse.jsReads a pasted target list into targets entries — names, RA and Dec in several notations, the priority and minutes columns — and a pasted run sheet into draft_rows with line, raw, start, end, minutes and the matched target.
/recon.jsFills each draft row's facts from the ephemeris and raises the row flags — before dusk, below the floor, too near the Moon, outside the target's window, overlapping the previous row, target not in the list.
# Fetch the engine once and keep it next to your script.
curl -sS -O https://observing-desk.skillsafe.ai/sky.js
curl -sS -O https://observing-desk.skillsafe.ai/parse.js
curl -sS -O https://observing-desk.skillsafe.ai/recon.js

# They are plain scripts with no imports, so in node the simplest loader is:
node --input-type=module -e '
  import { readFileSync } from "node:fs";
  import vm from "node:vm";
  const ctx = vm.createContext({ console });
  for (const f of ["sky.js", "parse.js", "recon.js"]) {
    vm.runInContext(readFileSync(f, "utf8"), ctx);
  }
  // ctx now holds the engine entry points; build `night` and `targets` with them,
  // then post the object below.
'

If you already have astropy, skyfield or your own ephemeris, use it — nothing about the API requires these scripts. What matters is that the numbers are right and that the times are the site's local clock in "HH:MM".

The plan request body, in full

This is the exact body the web page posts to /estimate, /run and /run-stream for the plan lane — seven targets at Kitt Peak on the night of 2026-10-14, one of them unobservable from that latitude. Remember: this object is the request body, with no wrapper around it, and every figure in it is one your ephemeris computed.

{
  "task": "plan",
  "site": {"name": "Kitt Peak, Arizona", "lat": 31.9633, "lon": -111.6, "height": 2096, "utc_offset": -7},
  "night": {
    "date": "2026-10-14", "utc_offset": -7,
    "sunset": "17:55", "sunrise": "06:30",
    "civil_dusk": "18:20", "civil_dawn": "06:05",
    "nautical_dusk": "18:48", "nautical_dawn": "05:37",
    "astronomical_dusk": "19:16", "astronomical_dawn": "05:09",
    "dark_start": "19:16", "dark_end": "05:09",
    "dark_kind": "astronomical", "dark_hours": 9.87,
    "lst_midnight": "01h18m57s",
    "moon": {"illumination_pct": 20, "phase": "waxing crescent", "rise": "11:25", "set": "20:21", "up_in_dark_pct": 10}
  },
  "constraints": {"min_alt": 30, "min_moon_sep": 30, "session_start": "", "session_end": ""},
  "preferences": "German equatorial mount; a meridian flip costs about ten minutes, so avoid slots that straddle transit. Camera cools by astronomical dusk. Favour the galaxies. Happy to stop by 04:30.",
  "targets": [
    { "name": "M31", "ra": "00h42m44.3s", "dec": "+41°16'09\"", "priority": 1, "minutes_wanted": 90, "note": "Andromeda, mosaic panel 2 of 3",
      "rise": "15:19", "transit": "23:35", "transit_alt": 80.5, "set": "07:52",
      "window_start": "19:16", "window_end": "04:40", "window_minutes": 564,
      "best_alt": 80.5, "best_at": "23:35", "best_airmass": 1.01, "moon_sep_midwindow": 129, "min_moon_sep": 130,
      "status": "observable", "flags": [],
      "alt_by_hour": { "17:30": 19, "18:00": 25, "18:30": 30, "19:00": 35, "19:30": 41, "20:00": 46, "20:30": 52,
          "21:00": 58, "21:30": 63, "22:00": 69, "22:30": 74, "23:00": 78, "23:30": 80, "00:00": 79,
          "00:30": 76, "01:00": 71, "01:30": 65, "02:00": 60, "02:30": 54, "03:00": 48, "03:30": 43,
          "04:00": 37, "04:30": 32, "05:00": 26, "05:30": 21, "06:00": 16, "06:30": 11} },
    { "name": "M33", "ra": "01h33m50.9s", "dec": "+30°39'37\"", "priority": 1, "minutes_wanted": 60, "note": "",
      "rise": "16:57", "transit": "00:26", "transit_alt": 88.8, "set": "07:56",
      "window_start": "19:40", "window_end": "05:09", "window_minutes": 568,
      "best_alt": 88.8, "best_at": "00:25", "best_airmass": 1, "moon_sep_midwindow": 137, "min_moon_sep": 140,
      "status": "observable", "flags": [],
      "alt_by_hour": { "17:30": 5, "18:00": 11, "18:30": 16, "19:00": 22, "19:30": 28, "20:00": 34, "20:30": 40,
          "21:00": 46, "21:30": 53, "22:00": 59, "22:30": 65, "23:00": 72, "23:30": 78, "00:00": 84,
          "00:30": 89, "01:00": 83, "01:30": 76, "02:00": 70, "02:30": 64, "03:00": 57, "03:30": 51,
          "04:00": 45, "04:30": 39, "05:00": 32, "05:30": 26, "06:00": 21, "06:30": 15} },
    { "name": "NGC 891", "ra": "02h22m33.4s", "dec": "+42°20'57\"", "priority": 2, "minutes_wanted": 60, "note": "edge-on",
      "rise": "16:53", "transit": "01:15", "transit_alt": 79.5, "set": "09:38",
      "window_start": "20:09", "window_end": "05:09", "window_minutes": 540,
      "best_alt": 79.5, "best_at": "01:15", "best_airmass": 1.02, "moon_sep_midwindow": 147, "min_moon_sep": 149,
      "status": "observable", "flags": [],
      "alt_by_hour": { "17:30": 4, "18:00": 9, "18:30": 13, "19:00": 18, "19:30": 23, "20:00": 28, "20:30": 34,
          "21:00": 39, "21:30": 45, "22:00": 50, "22:30": 56, "23:00": 61, "23:30": 67, "00:00": 72,
          "00:30": 76, "01:00": 79, "01:30": 79, "02:00": 76, "02:30": 72, "03:00": 67, "03:30": 61,
          "04:00": 56, "04:30": 50, "05:00": 45, "05:30": 39, "06:00": 34, "06:30": 29} },
    { "name": "NGC 7331", "ra": "22h37m04.1s", "dec": "+34°24'56\"", "priority": 2, "minutes_wanted": 45, "note": "",
      "rise": "13:46", "transit": "21:30", "transit_alt": 87.4, "set": "05:14",
      "window_start": "19:16", "window_end": "02:23", "window_minutes": 426,
      "best_alt": 87.2, "best_at": "21:25", "best_airmass": 1, "moon_sep_midwindow": 104, "min_moon_sep": 105,
      "status": "observable", "flags": [],
      "alt_by_hour": { "17:30": 40, "18:00": 46, "18:30": 53, "19:00": 59, "19:30": 65, "20:00": 71, "20:30": 77,
          "21:00": 83, "21:30": 87, "22:00": 83, "22:30": 77, "23:00": 71, "23:30": 65, "00:00": 59,
          "00:30": 52, "01:00": 46, "01:30": 40, "02:00": 34, "02:30": 29, "03:00": 23, "03:30": 17,
          "04:00": 12, "04:30": 7, "05:00": 2} },
    { "name": "Stephan's Quintet", "ra": "22h35m57.5s", "dec": "+33°57'36\"", "priority": 3, "minutes_wanted": 45, "note": "",
      "rise": "13:46", "transit": "21:29", "transit_alt": 87.9, "set": "05:11",
      "window_start": "19:16", "window_end": "02:21", "window_minutes": 424,
      "best_alt": 87.7, "best_at": "21:25", "best_airmass": 1, "moon_sep_midwindow": 103, "min_moon_sep": 104,
      "status": "observable", "flags": [],
      "alt_by_hour": { "17:30": 41, "18:00": 47, "18:30": 53, "19:00": 59, "19:30": 65, "20:00": 71, "20:30": 77,
          "21:00": 84, "21:30": 88, "22:00": 83, "22:30": 77, "23:00": 71, "23:30": 65, "00:00": 58,
          "00:30": 52, "01:00": 46, "01:30": 40, "02:00": 34, "02:30": 28, "03:00": 22, "03:30": 17,
          "04:00": 11, "04:30": 6, "05:00": 1} },
    { "name": "M42", "ra": "05h35m17.3s", "dec": "-05°23'28\"", "priority": 2, "minutes_wanted": 60, "note": "morning target",
      "rise": "22:39", "transit": "04:27", "transit_alt": 52.7, "set": "10:15",
      "window_start": "01:10", "window_end": "05:09", "window_minutes": 239,
      "best_alt": 52.7, "best_at": "04:25", "best_airmass": 1.26, "moon_sep_midwindow": 146, "min_moon_sep": 144,
      "status": "observable", "flags": [],
      "alt_by_hour": { "23:00": 4, "23:30": 10, "00:00": 16, "00:30": 22, "01:00": 28, "01:30": 34, "02:00": 39,
          "02:30": 43, "03:00": 47, "03:30": 50, "04:00": 52, "04:30": 53, "05:00": 52, "05:30": 50,
          "06:00": 47, "06:30": 42} },
    { "name": "Omega Centauri", "ra": "13h26m47.3s", "dec": "-47°28'46\"", "priority": 3, "minutes_wanted": 30, "note": "probably hopeless from here",
      "rise": "09:05", "transit": "12:21", "transit_alt": 10.4, "set": "15:34",
      "window_start": null, "window_end": null, "window_minutes": 0,
      "best_alt": null, "best_at": "—", "best_airmass": null, "moon_sep_midwindow": null, "min_moon_sep": 42,
      "status": "unobservable", "flags": ["never reaches 30 deg during darkness"],
      "alt_by_hour": {} }
  ]
}

/estimate creates no job and charges nothing. It returns the model binding — model, model_alias, markup_bps — and the reservation: hold_credits is what gets held, min_credits is the balance you must clear to start, and sponsor_enabled says whether the app is covering the run. The hold is a reservation, not the price. It prices the full output cap, so the charged_credits you see after settlement is usually far lower — often a small fraction of the hold. Budget against hold_credits, report against charged_credits. A long target list costs more than a short one, so estimate the body you are actually going to send.

# Save the body above as night.json - the object itself, with no {"input": ...}
# wrapper around it - then price it. /estimate is free.
INPUT=$(cat night.json)

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":2652,"min_credits":340,"sponsor_enabled":false}}
#
# estimate creates no job and charges nothing. hold_credits is what gets
# RESERVED; charged_credits after settlement is normally much lower.

5. Run it, then poll

POST /run returns a job_id; poll GET jobs/{job_id} until status is succeeded or failed. The reply JSON is the string at data.output.output. The terminal job also carries charged_credits — the real price — and the truncated flag.

Always send an Idempotency-Key. It is not formally required by the endpoint, and it is required in practice: derive it from the input as the web app does, a content hash plus an attempt counter (observing-desk:<hash>:a1). A retried request carrying the same key returns the same job instead of billing a second run, which is what makes an afternoon cron safe to re-run after a network blip. Because the hash covers night.date and every ephemeris figure, tomorrow's plan is a different key on its own; replaying a key with a different body is a 409 conflict, so bump the attempt suffix whenever you resend a changed body under the same intent.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="observing-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until the job reaches a terminal status.
while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# The terminal job looks like this:
# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"plan\",\"verdict\":\"ready\", ...}"},
#   "charged_credits":511,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])'

The check lane, in full

The second lane takes everything the first one does — alt_by_hour included, because the revised rows are read out of it exactly as a plan's are — and adds the observer's draft: the run sheet as pasted in draft_text, and your reading of it in draft_rows, one row per line with the facts your own reconciliation raised. This is the exact body the web page posts for a night at Siding Spring under a waning gibbous Moon, with a draft whose first row starts before dusk and whose last row points at a target that never rises there.

{
  "task": "check",
  "site": {"name": "Siding Spring, Australia", "lat": -31.2733, "lon": 149.0644, "height": 1165, "utc_offset": 10},
  "night": {
    "date": "2026-07-04", "utc_offset": 10,
    "sunset": "17:13", "sunrise": "07:03",
    "civil_dusk": "17:40", "civil_dawn": "06:36",
    "nautical_dusk": "18:10", "nautical_dawn": "06:06",
    "astronomical_dusk": "18:40", "astronomical_dawn": "05:37",
    "dark_start": "18:40", "dark_end": "05:37",
    "dark_kind": "astronomical", "dark_hours": 10.96,
    "lst_midnight": "18h56m40s",
    "moon": {"illumination_pct": 81, "phase": "waning gibbous", "rise": "21:06", "set": "10:11", "up_in_dark_pct": 76}
  },
  "constraints": {"min_alt": 30, "min_moon_sep": 30, "session_start": "", "session_end": ""},
  "preferences": "Refractor on a fork mount, no flip. Want the globular first while it is high.",
  "targets": [
    { "name": "Omega Centauri", "ra": "13h26m47.3s", "dec": "-47°28'46\"", "priority": 1, "minutes_wanted": 45, "note": "",
      "rise": "09:48", "transit": "18:43", "transit_alt": 73.7, "set": "03:33",
      "window_start": "18:40", "window_end": "23:55", "window_minutes": 316,
      "best_alt": 73.7, "best_at": "18:43", "best_airmass": 1.04, "moon_sep_midwindow": 109, "min_moon_sep": 109,
      "status": "observable", "flags": [],
      "alt_by_hour": { "17:00": 64, "17:30": 69, "18:00": 72, "18:30": 73, "19:00": 73, "19:30": 71, "20:00": 68,
          "20:30": 64, "21:00": 59, "21:30": 54, "22:00": 49, "22:30": 44, "23:00": 39, "23:30": 34,
          "00:00": 29, "00:30": 24, "01:00": 20, "01:30": 15, "02:00": 11, "02:30": 7, "03:00": 3} },
    { "name": "NGC 5139 Companion Field", "ra": "13h28m60.0s", "dec": "-47°00'00\"", "priority": 3, "minutes_wanted": 20, "note": "",
      "rise": "09:54", "transit": "18:45", "transit_alt": 74.1, "set": "03:32",
      "window_start": "18:40", "window_end": "23:57", "window_minutes": 317,
      "best_alt": 74.1, "best_at": "18:43", "best_airmass": 1.04, "moon_sep_midwindow": 109, "min_moon_sep": 109,
      "status": "observable", "flags": [],
      "alt_by_hour": { "17:00": 64, "17:30": 69, "18:00": 72, "18:30": 74, "19:00": 74, "19:30": 72, "20:00": 69,
          "20:30": 64, "21:00": 60, "21:30": 55, "22:00": 50, "22:30": 45, "23:00": 39, "23:30": 34,
          "00:00": 29, "00:30": 25, "01:00": 20, "01:30": 15, "02:00": 11, "02:30": 7, "03:00": 3} },
    { "name": "Eta Carinae", "ra": "10h45m03.6s", "dec": "-59°41'04\"", "priority": 2, "minutes_wanted": 45, "note": "",
      "rise": "—", "transit": "16:01", "transit_alt": 61.4, "set": "—",
      "window_start": "18:40", "window_end": "21:32", "window_minutes": 173,
      "best_alt": 50.8, "best_at": "18:43", "best_airmass": 1.29, "moon_sep_midwindow": 110, "min_moon_sep": 110,
      "status": "observable", "flags": ["circumpolar"],
      "alt_by_hour": { "17:00": 60, "17:30": 58, "18:00": 55, "18:30": 52, "19:00": 49, "19:30": 45, "20:00": 42,
          "20:30": 38, "21:00": 34, "21:30": 30, "22:00": 27, "22:30": 23, "23:00": 20, "23:30": 16,
          "00:00": 13, "00:30": 11, "01:00": 8, "01:30": 6, "02:00": 4, "02:30": 3, "03:00": 2,
          "03:30": 1, "04:00": 1, "04:30": 1, "05:00": 2, "05:30": 3, "06:00": 4, "06:30": 6,
          "07:00": 8} },
    { "name": "M83", "ra": "13h37m00.9s", "dec": "-29°51'56\"", "priority": 1, "minutes_wanted": 60, "note": "",
      "rise": "11:25", "transit": "18:53", "transit_alt": 88.7, "set": "02:17",
      "window_start": "18:40", "window_end": "23:36", "window_minutes": 297,
      "best_alt": 88.7, "best_at": "18:53", "best_airmass": 1, "moon_sep_midwindow": 118, "min_moon_sep": 118,
      "status": "observable", "flags": [],
      "alt_by_hour": { "17:00": 66, "17:30": 72, "18:00": 79, "18:30": 85, "19:00": 88, "19:30": 82, "20:00": 75,
          "20:30": 69, "21:00": 63, "21:30": 56, "22:00": 50, "22:30": 44, "23:00": 37, "23:30": 31,
          "00:00": 25, "00:30": 19, "01:00": 13, "01:30": 8, "02:00": 2} },
    { "name": "Sagittarius Star Cloud M24", "ra": "18h16m57.0s", "dec": "-18°30'00\"", "priority": 2, "minutes_wanted": 45, "note": "",
      "rise": "16:43", "transit": "23:32", "transit_alt": 77.2, "set": "06:21",
      "window_start": "19:10", "window_end": "03:53", "window_minutes": 523,
      "best_alt": 77.2, "best_at": "23:33", "best_airmass": 1.03, "moon_sep_midwindow": 60, "min_moon_sep": 59,
      "status": "observable", "flags": [],
      "alt_by_hour": { "17:00": 3, "17:30": 9, "18:00": 15, "18:30": 21, "19:00": 28, "19:30": 34, "20:00": 41,
          "20:30": 47, "21:00": 53, "21:30": 60, "22:00": 66, "22:30": 71, "23:00": 75, "23:30": 77,
          "00:00": 76, "00:30": 72, "01:00": 66, "01:30": 60, "02:00": 54, "02:30": 48, "03:00": 41,
          "03:30": 35, "04:00": 29, "04:30": 22, "05:00": 16, "05:30": 10, "06:00": 4} },
    { "name": "M31", "ra": "00h42m44.3s", "dec": "+41°16'09\"", "priority": 3, "minutes_wanted": 30, "note": "the one we always try",
      "rise": "02:03", "transit": "05:57", "transit_alt": 17.3, "set": "09:51",
      "window_start": null, "window_end": null, "window_minutes": 0,
      "best_alt": null, "best_at": "—", "best_airmass": null, "moon_sep_midwindow": null, "min_moon_sep": 58,
      "status": "unobservable", "flags": ["never reaches 30 deg during darkness"],
      "alt_by_hour": { "02:30": 3, "03:00": 7, "03:30": 10, "04:00": 12, "04:30": 15, "05:00": 16, "05:30": 17,
          "06:00": 17, "06:30": 17, "07:00": 16} }
  ],
  "draft_text": "18:00-18:45 Eta Carinae\n18:45 - 19:45: Omega Centauri\n19:45-20:45 M83\n21:00-21:45 Sagittarius Star Cloud M24\n04:30-05:00 M31",
  "draft_rows": [
    { "line": 1, "raw": "18:00-18:45 Eta Carinae",
      "start": "18:00", "end": "18:45", "minutes": 45, "target": "Eta Carinae",
      "facts": { "alt_start": 55.2, "alt_end": 50.5, "airmass_start": 1.22, "moon_sep": 109,
                 "moon_up": false, "in_dark": false, "in_window": false,
                 "flags": ["starts before astronomical dusk (18:40)"] } },
    { "line": 2, "raw": "18:45 - 19:45: Omega Centauri",
      "start": "18:45", "end": "19:45", "minutes": 60, "target": "Omega Centauri",
      "facts": { "alt_start": 73.7, "alt_end": 69.8, "airmass_start": 1.04, "moon_sep": 108,
                 "moon_up": false, "in_dark": true, "in_window": true,
                 "flags": [] } },
    { "line": 3, "raw": "19:45-20:45 M83",
      "start": "19:45", "end": "20:45", "minutes": 60, "target": "M83",
      "facts": { "alt_start": 78.7, "alt_end": 65.8, "airmass_start": 1.02, "moon_sep": 117,
                 "moon_up": false, "in_dark": true, "in_window": true,
                 "flags": [] } },
    { "line": 4, "raw": "21:00-21:45 Sagittarius Star Cloud M24",
      "start": "21:00", "end": "21:45", "minutes": 45, "target": "Sagittarius Star Cloud M24",
      "facts": { "alt_start": 53.4, "alt_end": 62.6, "airmass_start": 1.24, "moon_sep": 59,
                 "moon_up": true, "in_dark": true, "in_window": true,
                 "flags": [] } },
    { "line": 5, "raw": "04:30-05:00 M31",
      "start": "04:30", "end": "05:00", "minutes": 30, "target": "M31",
      "facts": { "alt_start": 14.6, "alt_end": 16.1, "airmass_start": 3.91, "moon_sep": 58,
                 "moon_up": true, "in_dark": true, "in_window": false,
                 "flags": ["below the 30 degree floor at 04:30 (15 degrees)",
                            "target is unobservable tonight: never reaches 30 deg during darkness"] } }
  ]
}

Two things are worth noticing in that body. Row 2's target matched even though the draft line is spelled "18:45 - 19:45: Omega Centauri" — matching the name is your job, and a row you cannot match takes "target": null, which the lane always calls a problem. And row 4 carries no flags even though the Moon is up at 59 degrees separation: it clears the 30-degree floor, so the browser found nothing wrong and the lane is free to say so too.

# Save the body above as draft.json and run the check lane. Same endpoints, same
# envelope, same Idempotency-Key discipline - only `task` and the two extra
# fields differ.
CHECK=$(cat draft.json)
CKEY="observing-desk:$(printf '%s' "$CHECK" | shasum -a 256 | cut -c1-16):a1"

CJOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $CKEY" \
  -d "$CHECK" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

# ... poll jobs/$CJOB exactly as above, then:
# {"lane":"check","verdict":"fixable","findings":[{"line":1,...}, ... 5 of them]}

6. Or stream it

POST /run-stream is the same call over server-sent events. Each delta event carries {"text": "..."}, a chunk of the reply JSON, and the final done event carries status, charged_credits — the real price, normally a fraction of the hold — and the truncated flag.

The practical tip: the web app does not parse the partial JSON to drive its progress display, it watches for key names arriving in the accumulating text. On the plan lane the appearance of "schedule" advances the stage from reading the night to ordering it, and "gaps" or "checklist" means the ordering is settled; on the check lane "findings" then "revised" do the same. Substring matching on the quoted key name is enough, and it costs nothing.

# Server-sent events. Each `delta` carries a chunk of the JSON reply; the final
# `done` event carries the status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"lane\":\"plan\",\"verdict\":\"ready\","}
# event: delta  {"text":"\"headline\":\"Three galaxies before"}
# event: done   {"status":"succeeded","charged_credits":511,"truncated":false}

7. Parse the reply

data.output.output is a string holding one JSON object. The web app strips an optional code fence, takes everything from the first { to the last }, parses that, and then normalizes it. Doing the same two things — the slice and the normalization — is what makes a caller robust against the small variations a model produces. Read lane first: it tells you which of the two contracts you are holding.

Here is the plan reply for the Kitt Peak body above, complete:

{
  "lane": "plan",
  "verdict": "ready",
  "headline": "All six observable targets fit inside your 04:30 stop, and both priority-1 galaxies take whole slots on one side of the meridian.",
  "night_summary": "Nine hours and fifty-two minutes of astronomical darkness, 19:16 to 05:09. The 20 percent crescent sets at 20:21, so only the first hour carries any Moon and every scheduled target sits more than 100 degrees from it. The ordering is governed at one end by the two Pegasus fields, whose windows close at 02:23 and 02:21, and at the other by M42, whose window does not open until 01:10.",
  "schedule": [
    { "order": 1, "target": "NGC 7331", "start": "19:40", "end": "20:25", "minutes": 45,
      "why": "Its window closes first, at 02:23, and the whole slot sits before the 21:30 transit so the mount does not flip mid-target.",
      "alt_start": 67, "alt_end": 76, "airmass_start": 1.09, "moon_sep": 104,
      "notes": "" },
    { "order": 2, "target": "Stephan's Quintet", "start": "20:25", "end": "21:10", "minutes": 45,
      "why": "The same patch of sky as NGC 7331 and a window that closes two minutes earlier; taking it next is a short slew and stays before its 21:29 transit.",
      "alt_start": 76, "alt_end": 85, "airmass_start": 1.03, "moon_sep": 103,
      "notes": "Ends 19 minutes before transit, so no meridian flip." },
    { "order": 3, "target": "M31", "start": "21:10", "end": "22:40", "minutes": 90,
      "why": "The full 90 minutes the mosaic panel asks for, climbing from 60 to 75 degrees and stopping short of the 23:35 transit so the German equatorial does not flip mid-panel.",
      "alt_start": 60, "alt_end": 75, "airmass_start": 1.15, "moon_sep": 129,
      "notes": "" },
    { "order": 4, "target": "M33", "start": "22:40", "end": "23:40", "minutes": 60,
      "why": "The second priority-1 target, rising to 80 degrees by the end of the hour and still 46 minutes short of its 00:26 transit.",
      "alt_start": 67, "alt_end": 80, "airmass_start": 1.09, "moon_sep": 137,
      "notes": "" },
    { "order": 5, "target": "NGC 891", "start": "23:40", "end": "00:40", "minutes": 60,
      "why": "Still rising toward its 01:15 transit at 79.5 degrees, and the whole hour fits before the meridian.",
      "alt_start": 69, "alt_end": 77, "airmass_start": 1.07, "moon_sep": 147,
      "notes": "" },
    { "order": 6, "target": "M42", "start": "03:20", "end": "04:20", "minutes": 60,
      "why": "Its window only opens at 01:10 and it transits at 04:27 at 52.7 degrees; this is the best hour the site can give it before your 04:30 stop.",
      "alt_start": 49, "alt_end": 53, "airmass_start": 1.33, "moon_sep": 146,
      "notes": "Ends seven minutes before transit." }
  ],
  "dropped": [
    { "target": "Omega Centauri", "reason": "never reaches 30 deg during darkness" }
  ],
  "gaps": [
    { "start": "19:16", "end": "19:40",
      "suggestion": "Flats and focus, as your preferences ask; NGC 7331 is already above 60 degrees if you want the extra 24 minutes." },
    { "start": "00:40", "end": "03:20",
      "suggestion": "Two hours and forty minutes. A second pass on M33, still at 87 degrees at 00:40 and past its transit, so no flip is needed." },
    { "start": "04:20", "end": "05:09",
      "suggestion": "You asked to stop by 04:30. If you stay, M42 holds above 50 degrees until about 05:00." }
  ],
  "risks": [
    "NGC 7331 and Stephan's Quintet share the same evening hours and both windows close near 02:22; if the start slips past 20:30, one of them loses its high slot and there is no second chance.",
    "M42 never rises above 52.7 degrees from this latitude, so airmass stays near 1.3 even at transit.",
    "The M42 slot ends seven minutes before transit. A late start pushes it across the meridian and costs the ten-minute flip you asked to avoid.",
    "The crescent Moon is up for the first 65 minutes. At 104 degrees from NGC 7331 it is no separation problem, but it will lift the background on the first slot.",
    "160 minutes between 00:40 and 03:20 have nothing scheduled; without a second pass, a third of the darkness goes unused."
  ],
  "checklist": [
    "17:55 sunset - open up and start cooling the camera, as your preferences ask.",
    "19:16 astronomical dusk - focus and flats finished by now.",
    "19:40 start NGC 7331; the slot ends well before its 21:30 transit, so no flip.",
    "20:21 Moon sets - the background drops for the rest of the night.",
    "21:10 start M31; the full 90 minutes ends at 22:40, short of the 23:35 transit.",
    "00:40 NGC 891 done - decide now how to spend the gap before M42's slot.",
    "03:20 start M42; it ends at 04:20, seven minutes short of transit.",
    "04:30 stop, as asked. Astronomical dawn is 05:09."
  ],
  "summary": "Six of the seven targets fit and every requested minute is honoured, including the full 90 on the M31 mosaic panel. The one dropped target is the one the engine says never clears 30 degrees from Arizona. The night is bounded by the Pegasus windows early and by your own 04:30 stop late, which leaves a 160-minute hole after NGC 891 that a second pass on M33 would fill without a meridian flip."
}

And here is the check reply for the Siding Spring draft, with one finding per draft row:

{
  "lane": "check",
  "verdict": "fixable",
  "headline": "Three of the five rows are sound; the opening slot starts in twilight and the closing one points at a target that never rises here.",
  "findings": [
    { "line": 1, "target": "Eta Carinae", "slot": "18:00-18:45", "status": "problem",
      "problem": "The slot opens 40 minutes before astronomical dusk at 18:40, so two thirds of it is twilight rather than dark time.",
      "fix": "Run it 20:45-21:30 instead - the last full slot inside its window, which closes at 21:32." },
    { "line": 2, "target": "Omega Centauri", "slot": "18:45-19:45", "status": "ok",
      "problem": "", "fix": "" },
    { "line": 3, "target": "M83", "slot": "19:45-20:45", "status": "ok",
      "problem": "", "fix": "" },
    { "line": 4, "target": "Sagittarius Star Cloud M24", "slot": "21:00-21:45", "status": "ok",
      "problem": "", "fix": "" },
    { "line": 5, "target": "M31", "slot": "04:30-05:00", "status": "problem",
      "problem": "M31 stands at about 15 degrees at 04:30, half the 30 degree floor, and the engine marks it unobservable from this site tonight.",
      "fix": "Drop the row. Nothing in the list is above the floor after 03:53, so the half hour is better spent on dark frames." }
  ],
  "revised": [
    { "order": 1, "target": "Omega Centauri", "start": "18:45", "end": "19:45", "minutes": 60,
      "why": "Unchanged. It transits at 18:43, so the draft already takes it at its highest, and the preference asks for the globular first.",
      "alt_start": 73, "alt_end": 70, "airmass_start": 1.05, "moon_sep": 109, "notes": "" },
    { "order": 2, "target": "M83", "start": "19:45", "end": "20:45", "minutes": 60,
      "why": "Unchanged. Still above 65 degrees throughout and 118 degrees from the Moon.",
      "alt_start": 78, "alt_end": 66, "airmass_start": 1.02, "moon_sep": 118, "notes": "" },
    { "order": 3, "target": "Eta Carinae", "start": "20:45", "end": "21:30", "minutes": 45,
      "why": "Moved out of twilight into the last slot its window allows; the window closes at 21:32.",
      "alt_start": 36, "alt_end": 30, "airmass_start": 1.70, "moon_sep": 110,
      "notes": "Two minutes of margin, and it ends exactly on the 30 degree floor. Start on time or lose it." },
    { "order": 4, "target": "Sagittarius Star Cloud M24", "start": "21:45", "end": "22:45", "minutes": 60,
      "why": "Shifted 45 minutes later to clear the Eta Carinae slot, which also puts it nearer its 23:32 transit.",
      "alt_start": 63, "alt_end": 73, "airmass_start": 1.12, "moon_sep": 60, "notes": "" }
  ],
  "notes": [
    "The draft opens in twilight and closes on an object that never rises here; the three middle rows are exactly right.",
    "Moving M24 later is a knock-on from the Eta Carinae fix, not a fault in the original row.",
    "NGC 5139 Companion Field is in the list and the draft never visits it, although it shares Omega Centauri's window.",
    "Nothing is planned after 22:45, and the star cloud stays above the floor until 03:53. The draft uses about a third of the available darkness."
  ],
  "summary": "Two rows to change and one to delete. Start at dusk rather than before it, give Eta Carinae the end of its short window instead of the start of the night, and treat the last half hour as calibration time."
}

What the normalizer does to it

The web app does not trust the reply verbatim, and neither should a caller. These are the behaviours you will actually hit:

Invariants worth asserting

# The reply JSON is a string inside the envelope, so unwrap it twice.
PLAN=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])')

printf '%s' "$PLAN" | python3 -c '
import sys, json
p = json.load(sys.stdin)
print(p["lane"], "|", p["verdict"], "|", p["headline"])
for r in p["schedule"]:
    print("  %d %s-%s %-26s alt %s-%s  X %s" % (
        r["order"], r["start"], r["end"], r["target"],
        r["alt_start"], r["alt_end"], r["airmass_start"]))
for d in p["dropped"]:
    print("  dropped %-26s %s" % (d["target"], d["reason"]))
'

# Every target you sent is in exactly one of schedule and dropped.
printf '%s' "$PLAN" | PLAN_IN="$INPUT" python3 -c '
import sys, json, os
p = json.load(sys.stdin)
sent = {t["name"] for t in json.loads(os.environ["PLAN_IN"])["targets"]}
placed = [r["target"] for r in p["schedule"]] + [d["target"] for d in p["dropped"]]
missing = sent - set(placed)
extra = set(placed) - sent
dupes = {n for n in placed if placed.count(n) != 1}
if missing or extra or dupes:
    raise SystemExit("partition broken: missing=%s extra=%s duplicated=%s" % (missing, extra, dupes))
print("every target placed exactly once")
'

# Every time is HH:MM on the local clock.
printf '%s' "$PLAN" | python3 -c '
import sys, json, re
p = json.load(sys.stdin)
bad = [r for r in p["schedule"]
       if not (re.fullmatch(r"[0-2][0-9]:[0-5][0-9]", r["start"])
               and re.fullmatch(r"[0-2][0-9]:[0-5][0-9]", r["end"]))]
if bad:
    raise SystemExit("not HH:MM: " + ", ".join(r["target"] for r in bad))
print("times are well formed")
'

The output contract

Both lanes share four keys and then diverge. Every array is always present: an array with nothing to say is [], never null and never omitted.

keytypemeaning
laneenumplan or check — the lane actually used, which is what to branch on when task was missing or unrecognised in the request.
verdictenumLane-specific: ready, thin or rework on plan; sound, fixable or rework on check. The single value a gate should branch on.
headlinestringOne sentence naming what decides the verdict.
summarystringTwo to four sentences closing the reply.

The plan lane

keytypemeaning
night_summarystringTwo to four sentences: how long the darkness is, what the Moon does, and the one thing that governs tonight's ordering.
scheduleobject[]{order, target, start, end, minutes, why, alt_start, alt_end, airmass_start, moon_sep, notes}, sorted by start and non-overlapping. target is a name from your input, spelled exactly as you sent it. alt_start and alt_end are read out of that target's alt_by_hour — the entry for that half hour, or the linear interpolation between the two neighbouring entries — airmass_start is 1 / sin(alt_start) to two decimals with 1.00 above 85 degrees, and moon_sep is the target's moon_sep_midwindow. All three are recomputable from your own input, so assert them. why is at most 60 words. notes carries a meridian-flip warning for a row straddling transit when the preferences mention a German equatorial mount, and "part 1 of 2" when a target is deliberately split.
droppedobject[]{target, reason} — every input target that is not in schedule. The reason is the engine's own flag for an unobservable target, and "no time left after higher priorities" or similar for the rest.
gapsobject[]{start, end, suggestion} — every stretch of dark time longer than 20 minutes with nothing scheduled, each with a one-line suggestion: a second pass, calibration frames, or leaving it.
risksstring[]Two to five risks specific to this night — a window that closes minutes after a slot ends, a bright Moon rising mid-session, a target scheduled below 35 degrees.
checkliststring[]Four to eight items in time order, for before and during this night. Each one usually opens with a clock time.

The check lane

keytypemeaning
findingsobject[]{line, target, slot, status, problem, fix} — exactly one entry per draft_rows row, in draft order, with the same line value and a slot of "start-end" taken from the row. problem and fix are empty strings on an ok row and at most 60 words each otherwise; a fix names a concrete new slot inside the target's window when one exists.
revisedobject[]The corrected sequence for the whole night, in the same row shape as plan.schedule, sorted by start and non-overlapping. Rows the observer got right stay exactly as they were; only flagged rows move, plus whatever has to shift to make room. Empty when the verdict is sound and nothing needed moving.
notesstring[]One to five observations about the draft as a whole — an unused hour of darkness, a target in the list the draft never visits, an order that fights the sky.

The enums

fieldvaluesnotes
laneplan, checkEchoes the task you sent, or the lane inferred from the fields present when task was missing. The two contracts are never blended, so read this before touching anything else.
verdict on planready, thin, reworkready: every observable priority-1 target is scheduled inside its window and the schedule is contiguous or has only deliberate gaps. thin: a priority-1 target had to be dropped, or fewer than half the wanted minutes fit. rework: nothing can be scheduled, or the constraints contradict the night — a session outside darkness, an altitude floor no target reaches.
verdict on checksound, fixable, reworksound: no row is problem and at most one is marginal. fixable: every problem row can be moved to a slot inside the same night. rework: more than half the rows are problem, or the draft's targets are mostly not in the list.
findings[].statusok, marginal, problemok is only available to a row whose facts.flags you sent empty. problem is mandatory for a row with in_dark false, in_window false, or a null target. marginal is for a flag that is only about margin — a Moon separation a few degrees under the floor, a slot ending within 15 minutes of the window closing.
targets[].status (input)observable, marginal, unobservableYours to set, and binding: an unobservable target is never scheduled and always comes back in dropped with your flag as the reason. A marginal target may be scheduled, with its caveat repeated in the row's notes.

The reply never invents astronomy. Every time, altitude, airmass and separation in it is one you sent or a conservative interpolation between two you sent, and a sentence that leans on general knowledge about a famous object is marked "(general knowledge)" in the text. Weather, seeing and equipment you did not describe are never mentioned.

8. Use it in CI

The worked example: a job that runs every afternoon, computes tonight's ephemeris for a standing target list with the engine scripts, plans the night, and exits non-zero when the verdict is not ready or a priority-1 target was dropped. The same shape gates a shared run sheet: send task: "check" with the file's rows and fail the build when any finding is a problem. Derive the Idempotency-Key from the input so a re-run for the same date and the same list replays the same job instead of re-billing — and note that the key changes by itself every night, because night.date and every ephemeris figure are inside the hash.

#!/bin/sh
# tonight.sh - plan the standing target list for tonight, every afternoon.
set -eu

BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="observing-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://observing-desk.skillsafe.ai/tokens.html

# 1. Compute tonight's ephemeris with the engine scripts. build-night.mjs is your
#    own wrapper around sky.js and parse.js: it reads site.json and targets.txt
#    and prints the whole request object on stdout.
INPUT=$(node build-night.mjs --date "$(date +%F)" --task plan)

# 2. Price it first so a shortfall is a clear message, not a 402 mid-cron.
call() { curl -sS "$@" -H "Authorization: Bearer $TOKEN"; }
call -X POST "$BASE/estimate" -H "Content-Type: application/json" -d "$INPUT" > /dev/null

KEY="observing-desk:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

while :; do
  OUT=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# 3. Gate on the verdict and on any priority-1 target that got dropped.
printf '%s' "$OUT" | PLAN_IN="$INPUT" python3 -c '
import sys, json, os
job = json.load(sys.stdin)["data"]
if job.get("truncated"):
    raise SystemExit("observing-desk: reply was truncated, the plan is a prefix")
p = json.loads(job["output"]["output"])
sent = json.loads(os.environ["PLAN_IN"])["targets"]
top = {t["name"] for t in sent if t["priority"] == 1 and t["status"] != "unobservable"}
lost = sorted(top & {d["target"] for d in p["dropped"]})
for r in p["schedule"]:
    print("%s-%s  %s" % (r["start"], r["end"], r["target"]))
print(p["verdict"], "-", p["headline"])
if p["verdict"] != "ready" or lost:
    raise SystemExit("observing-desk: verdict=%s, priority-1 dropped: %s"
                     % (p["verdict"], ", ".join(lost) or "none"))
'

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and comes back with truncated: true on the finished job and on the streaming done event. What you hold then is a prefix of the reply, not the reply — the schedule may be complete while gaps, risks, checklist and summary are missing or cut mid-string, and on the check lane findings can stop before the last draft row, which breaks the one-finding-per-row invariant outright.

Check the flag before you treat a reply as complete. The right response is a retry, not a repair: resubmit with a retry_note asking for a shorter reply — fewer risks, a four-item checklist, no second-pass suggestions — and with the attempt suffix on the Idempotency-Key incremented so the new body is not a replay of the old key. A long target list is the usual cause, so trimming the list to the targets that actually fit the night and sending clipped alongside it is often better than asking for a terser reply. Repairing truncated JSON by appending closing braces produces something that parses and is not what the model meant.