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
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The 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_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | The 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_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A 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"}}
# Open https://observing-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered plan or check.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "observing-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://observing-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered plan or check.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "observing-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://observing-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered plan or check.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"observing-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://observing-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered plan or check.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"observing-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://observing-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot run a metered plan or check.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "observing-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://observing-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered plan or check.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "observing-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://observing-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot run a metered plan or check.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"observing-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "observing-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://observing-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "observing-desk";
const TOKEN = "YOUR_TOKEN"; // from https://observing-desk.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "observing-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://observing-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class ObservingDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "observing-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "observing-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://observing-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "observing-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class ObservingDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "observing-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
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}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await ObservingDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
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.
task | what it does | extra input | the shape you get back |
|---|---|---|---|
plan | Orders the night: which target when, what to drop, where the gaps are, what to watch for. | none | schedule, dropped, gaps, risks, checklist, night_summary; verdict is ready, thin or rework. |
check | Judges the observer's own draft run sheet row by row against the same computed night, then rewrites it. | draft_text and draft_rows | findings (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
| field | type | meaning |
|---|---|---|
task | string, required | plan or check. |
site | object, 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. |
night | object, required | The 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. |
constraints | object, 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". |
preferences | string | Free 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. |
targets | object[], required | One 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. |
clipped | object, optional | {sent, total, dropped_names} when you trimmed a long list before sending it. Say so and the reply says so too. |
draft_text | string, check only | The observer's draft run sheet exactly as pasted, newline-separated. |
draft_rows | object[], check only | Your 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_note | string, optional | Send 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:
| script | what it gives you |
|---|---|
/sky.js | The 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.js | Reads 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.js | Fills 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.
# The whole body, abbreviated to two targets here; the full six-target object is
# printed above. Every number in night and targets comes from your ephemeris.
INPUT = {
"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. Happy to stop by 04:30.",
"targets": [
{"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": [],
# Every half hour the slot might touch. The reply's alt_start and alt_end are
# read out of this table and nowhere else.
"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": "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": None, "window_end": None, "window_minutes": 0,
"best_alt": None, "best_at": "—", "best_airmass": None,
"moon_sep_midwindow": None, "min_moon_sep": 42,
"status": "unobservable", "flags": ["never reaches 30 deg during darkness"],
"alt_by_hour": {}},
],
}
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["markup_bps"])
print(est["hold_credits"], est["min_credits"], est["sponsor_enabled"])
# estimate is free: no job is created and nothing is charged. The hold is a
# reservation against the full output cap, not the price of the run.
// The whole body, abbreviated to two targets here; the full six-target object is
// printed above. Every number in night and targets comes from your ephemeris.
const INPUT = {
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. Happy to stop by 04:30.",
targets: [
{ 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: [],
// Every half hour the slot might touch. The reply's alt_start and alt_end are
// read out of this table and nowhere else.
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: "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: {} },
],
};
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.markup_bps);
console.log(est.hold_credits, est.min_credits, est.sponsor_enabled);
// estimate is free: no job is created and nothing is charged. hold_credits is a
// reservation against the output cap; charged_credits is normally far lower.
// The body is large and entirely mechanical, so build it with your ephemeris code
// and keep it on disk. Here it is read back from night.json - the object itself,
// with no {"input": ...} wrapper.
raw, err := os.ReadFile("night.json")
if err != nil {
panic(err)
}
var input map[string]any
if err := json.Unmarshal(raw, &input); err != nil {
panic(err)
}
if input["task"] != "plan" && input["task"] != "check" {
panic("task must be plan or check")
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model, model_alias, markup_bps, hold_credits, min_credits
// estimate is free - no job, no charge; the hold is a reservation.
// The body is large and entirely mechanical, so build it with your ephemeris code
// and keep it on disk. Read it back and post it as-is - the object itself, with
// no {"input": ...} wrapper around it.
String input = java.nio.file.Files.readString(java.nio.file.Path.of("night.json"));
// The first field is the lane: "plan" orders the night, "check" judges a draft.
if (!input.contains("\"task\"")) {
throw new IllegalArgumentException("the body must carry a task field");
}
System.out.println(call("estimate", input));
// estimate is free: no job is created and nothing is charged.
// The data object carries model, model_alias, markup_bps, hold_credits,
// min_credits and sponsor_enabled. hold_credits is a reservation against the
// full output cap, so the settled charge is normally far lower.
# The body is large and entirely mechanical, so build it with your ephemeris code
# and keep it on disk. Here it is read back from night.json - the object itself,
# with no {"input" => ...} wrapper.
input = JSON.parse(File.read("night.json"))
raise "task must be plan or check" unless %w[plan check].include?(input["task"])
est = call("estimate", input)
puts "#{est['model']} hold=#{est['hold_credits']} min=#{est['min_credits']}"
# estimate is free: no job is created and nothing is charged.
<?php
// The body is large and entirely mechanical, so build it with your ephemeris code
// and keep it on disk. Here it is read back from night.json - the object itself,
// with no ["input" => ...] wrapper.
$input = json_decode(file_get_contents("night.json"), true);
if (!in_array($input["task"] ?? "", ["plan", "check"], true)) {
throw new RuntimeException("task must be plan or check");
}
$est = call("estimate", $input);
echo $est["model"], " ", $est["hold_credits"], " ", $est["min_credits"], PHP_EOL;
// estimate is free: no job is created and nothing is charged.
// The body is large and entirely mechanical, so build it with your ephemeris code
// and keep it on disk. Here it is read back from night.json - the object itself,
// with no {"input": ...} wrapper.
var input = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("night.json"));
var task = input.GetProperty("task").GetString();
if (task != "plan" && task != "check")
throw new Exception("task must be plan or check");
var est = await ObservingDesk.Call("estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
Console.WriteLine(est.GetProperty("sponsor_enabled").GetBoolean());
// estimate is free: no job is created and nothing is charged. The hold is a
// reservation against the output cap, not the price of the run.
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"])'
import hashlib, time
# 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.
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"observing-desk:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] == "succeeded":
break
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
time.sleep(2)
night = json.loads(job["output"]["output"])
print(night["lane"], night["verdict"], len(night["schedule"]), "slots")
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
// 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.
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `observing-desk:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const night = JSON.parse(job.output.output);
console.log(night.lane, night.verdict, night.schedule.length, "slots", job.charged_credits);
// 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.
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("observing-desk:%x:a1", sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
ChargedCredits int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
fmt.Println(job.Output.Output) // the plan or check JSON, as a string
fmt.Println(job.ChargedCredits, job.Truncated)
break
}
if job.Status == "failed" {
panic("run failed")
}
time.Sleep(2 * time.Second)
}
// 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.
var digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
var key = "observing-desk:" + java.util.HexFormat.of().formatHex(digest).substring(0, 16) + ":a1";
var start = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
String started = HTTP.send(start, HttpResponse.BodyHandlers.ofString()).body();
// Parse job_id out of `started`, then poll GET jobs/{job_id} every two seconds
// until status is "succeeded" or "failed". The reply JSON is data.output.output,
// and the terminal job also carries charged_credits and truncated.
System.out.println(started);
require "digest"
# 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.
digest = Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]
key = "observing-desk:#{digest}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(input)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("jobs/#{job_id}")
if job["status"] == "succeeded"
puts job["output"]["output"]
puts "charged=#{job['charged_credits']} truncated=#{job['truncated']}"
break
end
raise "run failed" if job["status"] == "failed"
sleep 2
end
<?php
// 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.
$digest = substr(hash("sha256", json_encode($input)), 0, 16);
$key = "observing-desk:{$digest}:a1";
$ch = curl_init(BASE . "/run");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
while (true) {
$job = call("jobs/" . $jobId);
if ($job["status"] === "succeeded") {
echo $job["output"]["output"];
echo PHP_EOL, "charged=", $job["charged_credits"], " truncated=", var_export($job["truncated"], true), PHP_EOL;
break;
}
if ($job["status"] === "failed") { throw new RuntimeException("run failed"); }
sleep(2);
}
using System.Security.Cryptography;
using System.Text;
// 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.
var json = JsonSerializer.Serialize(input);
var digest = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)))[..16].ToLowerInvariant();
var key = $"observing-desk:{digest}:a1";
var run = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
run.Headers.Add("Authorization", "Bearer YOUR_TOKEN");
run.Headers.Add("Idempotency-Key", key);
run.Content = JsonContent.Create(input);
// POST it, read data.job_id, then poll GET jobs/{job_id} every two seconds until
// status is "succeeded" or "failed". The reply JSON is data.output.output, and the
// terminal job also carries charged_credits and truncated.
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]}
# The check lane: same endpoints, same envelope. Only `task` and the two extra
# fields differ, so the same helper runs it.
CHECK = json.loads(open("draft.json", encoding="utf-8").read())
assert CHECK["task"] == "check" and CHECK["draft_rows"], "check needs draft_rows"
digest = hashlib.sha256(json.dumps(CHECK, sort_keys=True).encode()).hexdigest()[:16]
# ... POST /run with Idempotency-Key f"observing-desk:{digest}:a1", then poll.
report = json.loads(job["output"]["output"])
assert report["lane"] == "check"
assert len(report["findings"]) == len(CHECK["draft_rows"]) # one finding per row
for f in report["findings"]:
print(f"{f['line']:>2} {f['status']:8} {f['slot']:12} {f['target']} {f['problem']}")
print(report["verdict"], "-", report["headline"])
// The check lane: same endpoints, same envelope. Only `task` and the two extra
// fields differ, so the same helper runs it.
import { readFileSync } from "node:fs";
const CHECK = JSON.parse(readFileSync("draft.json", "utf8"));
if (CHECK.task !== "check" || !CHECK.draft_rows?.length) throw new Error("check needs draft_rows");
// ... POST /run with an Idempotency-Key over CHECK, then poll as above.
const report = JSON.parse(job.output.output);
if (report.findings.length !== CHECK.draft_rows.length) {
throw new Error("findings must have exactly one entry per draft row");
}
for (const f of report.findings) {
console.log(f.line, f.status.padEnd(8), f.slot, f.target, f.problem);
}
console.log(report.verdict, "-", report.headline);
// The check lane: same endpoints, same envelope. Only `task` and the two extra
// fields differ, so the same client runs it.
checkRaw, _ := os.ReadFile("draft.json")
var check map[string]any
_ = json.Unmarshal(checkRaw, &check)
rows, _ := check["draft_rows"].([]any)
if check["task"] != "check" || len(rows) == 0 {
panic("check needs draft_rows")
}
// ... POST /run with an Idempotency-Key over checkRaw, poll jobs/{job_id}, then:
var report struct {
Lane string `json:"lane"`
Verdict string `json:"verdict"`
Headline string `json:"headline"`
Findings []struct {
Line int `json:"line"`
Target string `json:"target"`
Slot string `json:"slot"`
Status string `json:"status"`
Problem string `json:"problem"`
Fix string `json:"fix"`
} `json:"findings"`
}
_ = json.Unmarshal([]byte(job.Output.Output), &report)
if len(report.Findings) != len(rows) {
panic("findings must have exactly one entry per draft row")
}
fmt.Println(report.Verdict, report.Headline)
// The check lane: same endpoints, same envelope. Only `task` and the two extra
// fields differ, so the same client runs it.
String check = java.nio.file.Files.readString(java.nio.file.Path.of("draft.json"));
// ... POST /run with an Idempotency-Key over `check`, poll jobs/{job_id}, then
// parse data.output.output and assert the two invariants that matter here:
//
// report.lane equals "check";
// report.findings.size() equals the number of draft_rows you sent, in the same
// order and with the same line numbers.
//
// A row whose facts.flags was non-empty is never "ok"; a row with in_dark false,
// in_window false or a null target is always "problem".
System.out.println(call("jobs/" + jobId, null));
# The check lane: same endpoints, same envelope. Only `task` and the two extra
# fields differ, so the same helper runs it.
check = JSON.parse(File.read("draft.json"))
abort "check needs draft_rows" unless check["task"] == "check" && !check["draft_rows"].empty?
# ... POST /run with an Idempotency-Key over `check`, then poll as above.
report = JSON.parse(job["output"]["output"])
raise "one finding per draft row" unless report["findings"].length == check["draft_rows"].length
report["findings"].each do |f|
puts format("%2d %-8s %-12s %s %s", f["line"], f["status"], f["slot"], f["target"], f["problem"])
end
puts "#{report['verdict']} - #{report['headline']}"
<?php
// The check lane: same endpoints, same envelope. Only `task` and the two extra
// fields differ, so the same helper runs it.
$check = json_decode(file_get_contents("draft.json"), true);
if (($check["task"] ?? "") !== "check" || empty($check["draft_rows"])) {
throw new RuntimeException("check needs draft_rows");
}
// ... POST /run with an Idempotency-Key over $check, then poll as above.
$report = json_decode($job["output"]["output"], true);
if (count($report["findings"]) !== count($check["draft_rows"])) {
throw new RuntimeException("findings must have exactly one entry per draft row");
}
foreach ($report["findings"] as $f) {
printf("%2d %-8s %-12s %s %s\n", $f["line"], $f["status"], $f["slot"], $f["target"], $f["problem"]);
}
echo $report["verdict"], " - ", $report["headline"], PHP_EOL;
// The check lane: same endpoints, same envelope. Only `task` and the two extra
// fields differ, so the same client runs it.
var check = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("draft.json"));
var rowCount = check.GetProperty("draft_rows").GetArrayLength();
if (check.GetProperty("task").GetString() != "check" || rowCount == 0)
throw new Exception("check needs draft_rows");
// ... POST /run with an Idempotency-Key over the body, poll jobs/{job_id}, then:
var report = JsonSerializer.Deserialize<JsonElement>(planJson);
if (report.GetProperty("findings").GetArrayLength() != rowCount)
throw new Exception("findings must have exactly one entry per draft row");
foreach (var f in report.GetProperty("findings").EnumerateArray())
Console.WriteLine($"{f.GetProperty("line")} {f.GetProperty("status")} {f.GetProperty("slot")}");
Console.WriteLine(report.GetProperty("verdict").GetString());
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}
# Server-sent events: the reply arrives in chunks, so a UI can show progress.
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
req.add_header("Accept", "text/event-stream")
raw = ""
done = {}
event = None
stage = "reading the night"
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
# The arrival of a key name is the progress signal the web app uses.
if '"checklist"' in raw or '"revised"' in raw:
stage = "writing it up"
elif '"schedule"' in raw or '"findings"' in raw:
stage = "ordering the night"
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
report = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
print(stage, report["lane"], report["verdict"], done.get("charged_credits"))
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
Accept: "text/event-stream",
},
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let raw = "";
let done = {};
let event = null;
let stage = "reading the night";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") {
raw += JSON.parse(line.slice(6)).text ?? "";
// The arrival of a key name is the progress signal the web app uses.
if (raw.includes('"checklist"') || raw.includes('"revised"')) stage = "writing it up";
else if (raw.includes('"schedule"') || raw.includes('"findings"')) stage = "ordering the night";
} else if (line.startsWith("data: ") && event === "done") {
done = JSON.parse(line.slice(6));
}
}
}
const report = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1));
console.log(stage, report.lane, report.verdict, done.charged_credits);
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()
var raw strings.Builder
var event string
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct {
Text string `json:"text"`
}
_ = json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &d)
raw.WriteString(d.Text)
// The arrival of "schedule" or "findings" advances the progress stage.
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println(strings.TrimPrefix(line, "data: ")) // status, charged_credits, truncated
}
}
fmt.Println(raw.String())
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
var stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
StringBuilder raw = new StringBuilder();
String[] event = { null };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
if (line.startsWith("event: ")) event[0] = line.substring(7);
else if (line.startsWith("data: ") && "delta".equals(event[0])) {
raw.append(line.substring(6)); // each data line is {"text":"..."} - decode and append .text
}
});
System.out.println(raw);
// Watch the accumulating text for "schedule" and "checklist" on the plan lane,
// or "findings" and "revised" on the check lane, to advance a progress display.
// The final `done` event carries status, charged_credits and truncated.
# Server-sent events: the reply arrives in chunks, so a UI can show progress.
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.generate(input)
raw = +""
event = nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta"
raw << (JSON.parse(line[6..])["text"] || "")
# The arrival of "schedule" or "findings" advances the progress stage.
end
end
end
end
end
report = JSON.parse(raw[raw.index("{")..raw.rindex("}")])
puts "#{report['lane']} #{report['verdict']}"
<?php
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
"Accept: text/event-stream",
]);
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) {
$event = substr($line, 7);
} elseif (str_starts_with($line, "data: ") && $event === "delta") {
$raw .= json_decode(substr($line, 6), true)["text"] ?? "";
}
}
return strlen($chunk);
});
curl_exec($ch);
curl_close($ch);
$report = json_decode(substr($raw, strpos($raw, "{")), true);
echo $report["lane"], " ", $report["verdict"], PHP_EOL;
// Server-sent events: the reply arrives in chunks, so a UI can show progress.
var stream = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
stream.Headers.Add("Authorization", "Bearer YOUR_TOKEN");
stream.Headers.Add("Idempotency-Key", key);
stream.Headers.Add("Accept", "text/event-stream");
stream.Content = JsonContent.Create(input);
using var res = await Http.SendAsync(stream, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event: ")) evt = line[7..];
else if (line.StartsWith("data: ") && evt == "delta")
{
var d = JsonSerializer.Deserialize<JsonElement>(line[6..]);
if (d.TryGetProperty("text", out var t)) raw.Append(t.GetString());
// Watch raw for "schedule" and "findings" to advance a progress display.
}
}
Console.WriteLine(raw.ToString());
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:
laneis read first, and it wins. A reply withfindingsis read as a check even iflanesaysplan, and the mismatch is worth logging.- An unrecognised plan
verdictbecomesthin; an unrecognised checkverdictbecomesfixable; an unrecognised findingstatusbecomesproblem. The safe direction is the pessimistic one. - A missing array is read as an empty array.
dropped,gaps,risks,checklist,revisedandnotesare nevernullafter normalization. - A schedule row naming a target that is not in the input list is dropped, and so is a row whose
startorenddoes not read as a clock time. Only the targets you sent exist. orderis backfilled positionally after sorting bystart, so a reply that numbered its rows out of order still renders in time order.- Times are normalized to
HH:MM: a bare9:05becomes09:05, and an hour from 00:00 to 11:59 is understood as the morning afternight.date. - If zero schedule rows survive that filter on a
planreply, parsing throws. That is the awkward case:/runsucceeded, you were charged, and the client still rejects the reply. Handle it as a retry with aretry_note, not as a transport error. - On a
checkreply, findings are matched to your rows byline. A finding for a line you did not send is dropped; if that leaves fewer findings than draft rows, the reply is a failed parse, not a partial one. - Empty strings in
risks,checklistandnotesare filtered out, so an array can come back shorter than the reply had it.
Invariants worth asserting
- Recompute every altitude and airmass. For each
scheduleorrevisedrow,alt_startandalt_endmust equal that target'salt_by_hourat those times, interpolated linearly between the neighbouring half hours and rounded to the nearest degree;airmass_startmust equal1 / sin(alt_start)to two decimals, or1.00above 85 degrees;moon_sepmust equalmoon_sep_midwindowexactly. Allow one degree of rounding slack on the altitudes and nothing at all onmoon_sep. This is the check that catches a reply that quietly invented a figure. - Every target you sent appears in exactly one of
scheduleanddropped— never both, never neither. This is the single strongest check on a plan reply. - No target with
statusunobservableappears inschedule. findingshas exactly the same length as thedraft_rowsyou sent, with the samelinevalues in the same order.- A row whose
facts.flagswas non-empty is neverok; a row within_darkfalse,in_windowfalse or anulltarget is alwaysproblem. - Every time is
HH:MMon the site's local clock — match^[0-2][0-9]:[0-5][0-9]$and reject anything else. - Every
scheduleandrevisedrow lies insidenight.dark_start..night.dark_end, insideconstraints.session_start..constraints.session_endwhen those are set, and inside its own target'swindow_start..window_end. scheduleandrevisedare sorted bystartand do not overlap;endminusstartequalsminutes, remembering that a slot can cross midnight.- No target appears twice in
scheduleunless the split is deliberate, in which casenotessays so. okfindings have emptyproblemandfix; every other finding has both.
# 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")
'
import math, re
report = json.loads(job["output"]["output"])
assert report["lane"] in ("plan", "check"), report.get("lane")
HHMM = re.compile(r"[0-2][0-9]:[0-5][0-9]")
def minutes(hhmm):
"""Clock time to minutes from noon, so an evening sorts before a morning."""
h, m = (int(x) for x in hhmm.split(":"))
return ((h - 12) % 24) * 60 + m
def alt_at(t, hhmm):
"""The only altitude the reply may use: alt_by_hour, linearly interpolated."""
keys = sorted(t["alt_by_hour"], key=minutes)
x = minutes(hhmm)
for a, b in zip(keys, keys[1:]):
if minutes(a) <= x <= minutes(b):
span = minutes(b) - minutes(a)
f = (x - minutes(a)) / span if span else 0
return t["alt_by_hour"][a] + f * (t["alt_by_hour"][b] - t["alt_by_hour"][a])
raise AssertionError(f"{t['name']} has no alt_by_hour covering {hhmm}")
def airmass(alt):
return 1.0 if alt > 85 else round(1 / math.sin(math.radians(alt)), 2)
def check_rows(rows, targets):
by_name = {t["name"]: t for t in targets}
prev_end = None
for r in rows:
assert HHMM.fullmatch(r["start"]) and HHMM.fullmatch(r["end"]), r
t = by_name[r["target"]] # only sent targets exist
assert t["status"] != "unobservable", r["target"]
start, end = minutes(r["start"]), minutes(r["end"])
assert end - start == r["minutes"], r
assert minutes(t["window_start"]) <= start and end <= minutes(t["window_end"]), r
assert minutes(INPUT["night"]["dark_start"]) <= start, r
assert end <= minutes(INPUT["night"]["dark_end"]), r
# The figures are arithmetic, so recompute them rather than trusting them.
assert abs(r["alt_start"] - alt_at(t, r["start"])) <= 1, f"alt_start invented: {r}"
assert abs(r["alt_end"] - alt_at(t, r["end"])) <= 1, f"alt_end invented: {r}"
assert abs(r["airmass_start"] - airmass(round(r["alt_start"]))) <= 0.02, r
assert r["moon_sep"] == t["moon_sep_midwindow"], r
if prev_end is not None:
assert start >= prev_end, "rows overlap or are out of order"
prev_end = end
if report["lane"] == "plan":
# Every target appears in exactly one of schedule and dropped.
sent = [t["name"] for t in INPUT["targets"]]
placed = [r["target"] for r in report["schedule"]] + [d["target"] for d in report["dropped"]]
assert sorted(placed) == sorted(sent), f"partition broken: {sorted(placed)} != {sorted(sent)}"
assert report["verdict"] in ("ready", "thin", "rework"), report["verdict"]
check_rows(report["schedule"], INPUT["targets"])
for r in report["schedule"]:
print(f"{r['order']} {r['start']}-{r['end']} {r['target']:26} alt {r['alt_start']}-{r['alt_end']}")
for d in report["dropped"]:
print(f" dropped {d['target']:26} {d['reason']}")
else:
# Exactly one finding per draft row, in order, with the same line numbers.
rows = CHECK["draft_rows"]
assert len(report["findings"]) == len(rows), "one finding per draft row"
assert [f["line"] for f in report["findings"]] == [r["line"] for r in rows]
assert report["verdict"] in ("sound", "fixable", "rework"), report["verdict"]
for f, row in zip(report["findings"], rows):
assert f["status"] in ("ok", "marginal", "problem"), f
if row["facts"]["flags"]:
assert f["status"] != "ok", f"row {f['line']} was flagged but came back ok"
if not row["facts"]["in_dark"] or not row["facts"]["in_window"] or row["target"] is None:
assert f["status"] == "problem", f"row {f['line']} should be a problem"
if f["status"] == "ok":
assert f["problem"] == "" and f["fix"] == ""
print(f"{f['line']:>2} {f['status']:8} {f['slot']:12} {f['target']} {f['problem']}")
check_rows(report["revised"], CHECK["targets"])
const report = JSON.parse(job.output.output);
const HHMM = /^[0-2][0-9]:[0-5][0-9]$/;
// Clock time to minutes from noon, so an evening sorts before a morning.
const mins = (t) => {
const [h, m] = t.split(":").map(Number);
return (((h - 12) % 24) + 24) % 24 * 60 + m;
};
// The only altitude the reply may use: alt_by_hour, linearly interpolated.
const altAt = (t, hhmm) => {
const keys = Object.keys(t.alt_by_hour).sort((a, b) => mins(a) - mins(b));
const x = mins(hhmm);
for (let i = 0; i < keys.length - 1; i++) {
const a = keys[i], b = keys[i + 1];
if (mins(a) <= x && x <= mins(b)) {
const span = mins(b) - mins(a);
const f = span ? (x - mins(a)) / span : 0;
return t.alt_by_hour[a] + f * (t.alt_by_hour[b] - t.alt_by_hour[a]);
}
}
throw new Error(`${t.name} has no alt_by_hour covering ${hhmm}`);
};
const airmass = (alt) => (alt > 85 ? 1 : Math.round(100 / Math.sin((alt * Math.PI) / 180)) / 100);
function checkRows(rows, targets, night) {
const byName = new Map(targets.map((t) => [t.name, t]));
let prevEnd = null;
for (const r of rows) {
if (!HHMM.test(r.start) || !HHMM.test(r.end)) throw new Error(`not HH:MM: ${r.target}`);
const t = byName.get(r.target);
if (!t) throw new Error(`invented target: ${r.target}`);
if (t.status === "unobservable") throw new Error(`scheduled an unobservable target: ${r.target}`);
const start = mins(r.start), end = mins(r.end);
if (end - start !== r.minutes) throw new Error(`minutes do not match the slot: ${r.target}`);
if (start < mins(t.window_start) || end > mins(t.window_end)) {
throw new Error(`outside its window: ${r.target}`);
}
if (start < mins(night.dark_start) || end > mins(night.dark_end)) {
throw new Error(`outside the dark window: ${r.target}`);
}
// The figures are arithmetic, so recompute them rather than trusting them.
if (Math.abs(r.alt_start - altAt(t, r.start)) > 1) throw new Error(`alt_start invented: ${r.target}`);
if (Math.abs(r.alt_end - altAt(t, r.end)) > 1) throw new Error(`alt_end invented: ${r.target}`);
if (Math.abs(r.airmass_start - airmass(Math.round(r.alt_start))) > 0.02) {
throw new Error(`airmass_start is not 1/sin(alt_start): ${r.target}`);
}
if (r.moon_sep !== t.moon_sep_midwindow) throw new Error(`moon_sep is not moon_sep_midwindow: ${r.target}`);
if (prevEnd !== null && start < prevEnd) throw new Error("rows overlap or are out of order");
prevEnd = end;
}
}
if (report.lane === "plan") {
// Every target appears in exactly one of schedule and dropped.
const sent = INPUT.targets.map((t) => t.name).sort();
const placed = [...report.schedule.map((r) => r.target),
...report.dropped.map((d) => d.target)].sort();
if (JSON.stringify(sent) !== JSON.stringify(placed)) {
throw new Error(`partition broken: ${placed} vs ${sent}`);
}
checkRows(report.schedule, INPUT.targets, INPUT.night);
for (const r of report.schedule) console.log(r.order, r.start, r.end, r.target, r.airmass_start);
} else {
// Exactly one finding per draft row, in order, with the same line numbers.
const rows = CHECK.draft_rows;
if (report.findings.length !== rows.length) throw new Error("one finding per draft row");
report.findings.forEach((f, i) => {
const row = rows[i];
if (f.line !== row.line) throw new Error("findings are out of draft order");
if (row.facts.flags.length && f.status === "ok") throw new Error(`row ${f.line} was flagged`);
if ((!row.facts.in_dark || !row.facts.in_window || row.target === null) && f.status !== "problem") {
throw new Error(`row ${f.line} should be a problem`);
}
console.log(f.line, f.status, f.slot, f.target, f.problem);
});
checkRows(report.revised, CHECK.targets, CHECK.night);
}
type row struct {
Order int `json:"order"`
Target string `json:"target"`
Start string `json:"start"`
End string `json:"end"`
Minutes int `json:"minutes"`
Why string `json:"why"`
AltStart float64 `json:"alt_start"`
AltEnd float64 `json:"alt_end"`
AirmassStart float64 `json:"airmass_start"`
MoonSep float64 `json:"moon_sep"`
Notes string `json:"notes"`
}
type finding struct {
Line int `json:"line"`
Target string `json:"target"`
Slot string `json:"slot"`
Status string `json:"status"`
Problem string `json:"problem"`
Fix string `json:"fix"`
}
type report struct {
Lane string `json:"lane"`
Verdict string `json:"verdict"`
Headline string `json:"headline"`
Summary string `json:"summary"`
Schedule []row `json:"schedule"`
Dropped []struct {
Target string `json:"target"`
Reason string `json:"reason"`
} `json:"dropped"`
Gaps []struct {
Start string `json:"start"`
End string `json:"end"`
Suggestion string `json:"suggestion"`
} `json:"gaps"`
Risks []string `json:"risks"`
Checklist []string `json:"checklist"`
Findings []finding `json:"findings"`
Revised []row `json:"revised"`
Notes []string `json:"notes"`
}
var rep report
if err := json.Unmarshal([]byte(job.Output.Output), &rep); err != nil {
panic(err)
}
// Every target you sent is in exactly one of schedule and dropped.
if rep.Lane == "plan" {
placed := map[string]int{}
for _, r := range rep.Schedule {
placed[r.Target]++
}
for _, d := range rep.Dropped {
placed[d.Target]++
}
for _, t := range sentTargets { // the names you posted, in order
if placed[t] != 1 {
panic("target placed " + fmt.Sprint(placed[t]) + " times: " + t)
}
}
if len(placed) != len(sentTargets) {
panic("the reply names a target that was not sent")
}
for _, r := range rep.Schedule {
fmt.Println(r.Order, r.Start+"-"+r.End, r.Target, r.AirmassStart)
}
} else {
// Exactly one finding per draft row, in draft order.
if len(rep.Findings) != len(draftRows) {
panic("findings must have exactly one entry per draft row")
}
for i, f := range rep.Findings {
if f.Line != draftRows[i].Line {
panic("findings are out of draft order")
}
if len(draftRows[i].Facts.Flags) > 0 && f.Status == "ok" {
panic(fmt.Sprintf("row %d was flagged but came back ok", f.Line))
}
fmt.Println(f.Line, f.Status, f.Slot, f.Target)
}
}
// The reply JSON is a string inside data.output.output. Parse it, read `lane`,
// then assert the invariants for that lane before you trust a single figure:
//
// plan
// 1. every target you sent appears in exactly one of schedule and dropped,
// and nothing else appears in either;
// 2. no target whose status was "unobservable" is in schedule;
// 3. schedule is sorted by start, rows do not overlap, and each row lies
// inside night.dark_start..dark_end and inside its own target's
// window_start..window_end;
// 4. end minus start equals minutes, remembering a slot may cross midnight;
// 5. alt_start and alt_end equal that target's alt_by_hour at those times,
// interpolated between the neighbouring half hours, airmass_start equals
// 1/sin(alt_start) to two decimals (1.00 above 85 degrees), and moon_sep
// equals moon_sep_midwindow - all recomputable from your own input;
// 6. verdict is one of ready, thin, rework.
//
// check
// 1. findings has exactly one entry per draft_rows row, in the same order
// and with the same line numbers;
// 2. a row whose facts.flags was non-empty is never "ok", and a row with
// in_dark false, in_window false or a null target is always "problem";
// 3. an "ok" finding has an empty problem and an empty fix;
// 4. revised rows satisfy the same geometry as a plan schedule;
// 5. verdict is one of sound, fixable, rework.
//
// Every time in either lane matches ^[0-2][0-9]:[0-5][0-9]$ and is the site's
// local clock: 12:00 to 23:59 is the evening of night.date, 00:00 to 11:59 the
// morning after. A `truncated` job is a prefix, not a plan: resubmit with a
// retry_note and an incremented attempt suffix on the Idempotency-Key.
String replyJson = /* data.output.output */ call("jobs/" + jobId, null);
System.out.println(replyJson);
report = JSON.parse(job["output"]["output"])
HHMM = /\A[0-2]\d:[0-5]\d\z/
# Clock time to minutes from noon, so an evening sorts before a morning.
def mins(t)
h, m = t.split(":").map(&:to_i)
((h - 12) % 24) * 60 + m
end
def check_rows(rows, targets, night)
by_name = targets.to_h { |t| [t["name"], t] }
prev_end = nil
rows.each do |r|
raise "not HH:MM: #{r['target']}" unless r["start"] =~ HHMM && r["end"] =~ HHMM
t = by_name.fetch(r["target"]) { raise "invented target: #{r['target']}" }
raise "scheduled an unobservable target" if t["status"] == "unobservable"
s, e = mins(r["start"]), mins(r["end"])
raise "minutes do not match the slot" unless e - s == r["minutes"]
raise "outside its window" if s < mins(t["window_start"]) || e > mins(t["window_end"])
raise "outside the dark window" if s < mins(night["dark_start"]) || e > mins(night["dark_end"])
raise "rows overlap" if prev_end && s < prev_end
prev_end = e
end
end
if report["lane"] == "plan"
sent = input["targets"].map { |t| t["name"] }.sort
placed = (report["schedule"].map { |r| r["target"] } +
report["dropped"].map { |d| d["target"] }).sort
raise "partition broken: #{placed} vs #{sent}" unless placed == sent
check_rows(report["schedule"], input["targets"], input["night"])
report["schedule"].each { |r| puts "#{r['order']} #{r['start']}-#{r['end']} #{r['target']}" }
else
rows = check["draft_rows"]
raise "one finding per draft row" unless report["findings"].length == rows.length
report["findings"].each_with_index do |f, i|
raise "findings are out of draft order" unless f["line"] == rows[i]["line"]
raise "row #{f['line']} was flagged but came back ok" if
!rows[i]["facts"]["flags"].empty? && f["status"] == "ok"
puts format("%2d %-8s %-12s %s", f["line"], f["status"], f["slot"], f["target"])
end
check_rows(report["revised"], check["targets"], check["night"])
end
<?php
$report = json_decode($job["output"]["output"], true);
// Clock time to minutes from noon, so an evening sorts before a morning.
function mins(string $t): int {
[$h, $m] = array_map("intval", explode(":", $t));
return ((($h - 12) % 24) + 24) % 24 * 60 + $m;
}
function check_rows(array $rows, array $targets, array $night): void {
$byName = array_column($targets, null, "name");
$prevEnd = null;
foreach ($rows as $r) {
if (!preg_match('/^[0-2][0-9]:[0-5][0-9]$/', $r["start"])) {
throw new RuntimeException("not HH:MM: " . $r["target"]);
}
$t = $byName[$r["target"]] ?? throw new RuntimeException("invented target: " . $r["target"]);
if ($t["status"] === "unobservable") {
throw new RuntimeException("scheduled an unobservable target: " . $r["target"]);
}
$s = mins($r["start"]); $e = mins($r["end"]);
if ($e - $s !== $r["minutes"]) { throw new RuntimeException("minutes do not match the slot"); }
if ($s < mins($t["window_start"]) || $e > mins($t["window_end"])) {
throw new RuntimeException("outside its window: " . $r["target"]);
}
if ($s < mins($night["dark_start"]) || $e > mins($night["dark_end"])) {
throw new RuntimeException("outside the dark window: " . $r["target"]);
}
if ($prevEnd !== null && $s < $prevEnd) { throw new RuntimeException("rows overlap"); }
$prevEnd = $e;
}
}
if ($report["lane"] === "plan") {
$sent = array_column($input["targets"], "name");
$placed = array_merge(array_column($report["schedule"], "target"),
array_column($report["dropped"], "target"));
sort($sent); sort($placed);
if ($sent !== $placed) { throw new RuntimeException("every target must be placed exactly once"); }
check_rows($report["schedule"], $input["targets"], $input["night"]);
foreach ($report["schedule"] as $r) {
printf("%d %s-%s %s\n", $r["order"], $r["start"], $r["end"], $r["target"]);
}
} else {
$rows = $check["draft_rows"];
if (count($report["findings"]) !== count($rows)) {
throw new RuntimeException("one finding per draft row");
}
foreach ($report["findings"] as $i => $f) {
if ($f["line"] !== $rows[$i]["line"]) { throw new RuntimeException("out of draft order"); }
if ($rows[$i]["facts"]["flags"] && $f["status"] === "ok") {
throw new RuntimeException("row {$f['line']} was flagged but came back ok");
}
printf("%2d %-8s %-12s %s\n", $f["line"], $f["status"], $f["slot"], $f["target"]);
}
check_rows($report["revised"], $check["targets"], $check["night"]);
}
var report = JsonSerializer.Deserialize<JsonElement>(replyJson);
var lane = report.GetProperty("lane").GetString();
// Clock time to minutes from noon, so an evening sorts before a morning.
static int Mins(string t)
{
var parts = t.Split(':');
var h = int.Parse(parts[0]);
var m = int.Parse(parts[1]);
return (((h - 12) % 24) + 24) % 24 * 60 + m;
}
if (lane == "plan")
{
// Every target you sent is in exactly one of schedule and dropped.
var placed = report.GetProperty("schedule").EnumerateArray()
.Select(r => r.GetProperty("target").GetString())
.Concat(report.GetProperty("dropped").EnumerateArray()
.Select(d => d.GetProperty("target").GetString()))
.ToList();
foreach (var name in sentTargetNames)
if (placed.Count(p => p == name) != 1)
throw new Exception($"target placed {placed.Count(p => p == name)} times: {name}");
if (placed.Count != sentTargetNames.Count)
throw new Exception("the reply names a target that was not sent");
foreach (var r in report.GetProperty("schedule").EnumerateArray())
{
var start = Mins(r.GetProperty("start").GetString()!);
var end = Mins(r.GetProperty("end").GetString()!);
if (end - start != r.GetProperty("minutes").GetInt32())
throw new Exception("minutes do not match the slot");
Console.WriteLine($"{r.GetProperty("order")} {r.GetProperty("start")} {r.GetProperty("target")}");
}
}
else
{
// Exactly one finding per draft row, in draft order.
var findings = report.GetProperty("findings").EnumerateArray().ToList();
if (findings.Count != draftRows.Count)
throw new Exception("findings must have exactly one entry per draft row");
for (var i = 0; i < findings.Count; i++)
{
if (findings[i].GetProperty("line").GetInt32() != draftRows[i].Line)
throw new Exception("findings are out of draft order");
if (draftRows[i].Facts.Flags.Count > 0 && findings[i].GetProperty("status").GetString() == "ok")
throw new Exception($"row {draftRows[i].Line} was flagged but came back ok");
}
}
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.
| key | type | meaning |
|---|---|---|
lane | enum | plan or check — the lane actually used, which is what to branch on when task was missing or unrecognised in the request. |
verdict | enum | Lane-specific: ready, thin or rework on plan; sound, fixable or rework on check. The single value a gate should branch on. |
headline | string | One sentence naming what decides the verdict. |
summary | string | Two to four sentences closing the reply. |
The plan lane
| key | type | meaning |
|---|---|---|
night_summary | string | Two to four sentences: how long the darkness is, what the Moon does, and the one thing that governs tonight's ordering. |
schedule | object[] | {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. |
dropped | object[] | {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. |
gaps | object[] | {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. |
risks | string[] | 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. |
checklist | string[] | Four to eight items in time order, for before and during this night. Each one usually opens with a clock time. |
The check lane
| key | type | meaning |
|---|---|---|
findings | object[] | {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. |
revised | object[] | 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. |
notes | string[] | 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
| field | values | notes |
|---|---|---|
lane | plan, check | Echoes 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 plan | ready, thin, rework | ready: 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 check | sound, fixable, rework | sound: 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[].status | ok, marginal, problem | ok 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, unobservable | Yours 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"))
'
#!/usr/bin/env python3
"""tonight.py - plan the standing target list for tonight, every afternoon.
Reuses the `call` helper from section 2. Exits 1 when the verdict is not
"ready", when an observable priority-1 target was dropped, or on a truncated
reply, which is a prefix and not a plan.
"""
import hashlib, json, subprocess, sys, time, urllib.request
# build_night.mjs is your own wrapper around sky.js and parse.js: it reads the
# site and the standing target list and prints the whole request object.
INPUT = json.loads(subprocess.run(
["node", "build_night.mjs", "--task", "plan"],
check=True, capture_output=True, text=True).stdout)
assert INPUT["task"] == "plan" and INPUT["targets"], "nothing to plan"
est = call("estimate", INPUT) # free, and it catches a 402 early
me = call("me")
if me.get("credits", 0) < est["min_credits"]:
sys.exit(f"observing-desk: balance {me['credits']} is under min_credits {est['min_credits']}")
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", f"observing-desk:{digest}:a1")
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
sys.exit(f"observing-desk: run failed: {job.get('error')}")
if job.get("truncated"):
sys.exit("observing-desk: reply was truncated - the plan is a prefix, not a plan")
plan = json.loads(job["output"]["output"])
top = {t["name"] for t in INPUT["targets"] if t["priority"] == 1 and t["status"] != "unobservable"}
lost = sorted(top & {d["target"] for d in plan["dropped"]})
print(plan["night_summary"])
for r in plan["schedule"]:
print(f" {r['start']}-{r['end']} {r['target']:26} alt {r['alt_start']}-{r['alt_end']} X {r['airmass_start']}")
for g in plan["gaps"]:
print(f" gap {g['start']}-{g['end']}: {g['suggestion']}")
for line in plan["checklist"]:
print(" -", line)
if plan["verdict"] != "ready" or lost:
sys.exit(f"observing-desk: verdict={plan['verdict']}, priority-1 dropped: {', '.join(lost) or 'none'}")
print("observing-desk: ready. Charged", job.get("charged_credits"), "credits.")
// run-sheet-gate.mjs - refuse a shared run sheet whose rows do not survive the night.
// Reuses the `call` helper from section 2.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
// build-night.mjs wraps sky.js, parse.js and recon.js: it reads the site, the
// target list and the run sheet, and returns the whole check-lane request object.
const { buildCheck } = await import("./build-night.mjs");
const CHECK = buildCheck({
site: JSON.parse(readFileSync("site.json", "utf8")),
targets: readFileSync("targets.txt", "utf8"),
draft: readFileSync("run-sheet.txt", "utf8"),
date: new Date().toISOString().slice(0, 10),
});
if (!CHECK.draft_rows.length) throw new Error("observing-desk: the run sheet is empty");
const digest = createHash("sha256").update(JSON.stringify(CHECK)).digest("hex").slice(0, 16);
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": `observing-desk:${digest}:a1`,
},
body: JSON.stringify(CHECK),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error("observing-desk: run failed");
if (job.truncated) throw new Error("observing-desk: reply was truncated");
const report = JSON.parse(job.output.output);
if (report.findings.length !== CHECK.draft_rows.length) {
throw new Error("observing-desk: one finding per draft row, or the reply is unusable");
}
for (const f of report.findings) {
console.log(`${f.line} ${f.status.padEnd(8)} ${f.slot} ${f.target} ${f.problem}`);
if (f.fix) console.log(` fix: ${f.fix}`);
}
const problems = report.findings.filter((f) => f.status === "problem");
console.log(report.verdict, "-", report.headline);
if (problems.length) {
console.error(`observing-desk: ${problems.length} unusable row(s): ${problems.map((f) => f.line).join(", ")}`);
process.exitCode = 1;
}
// The gate, on top of the client and the report struct from the earlier sections:
// build tonight's request with your ephemeris code, run the plan lane, exit
// non-zero on anything but a "ready" verdict or a dropped priority-1 target.
out, err := exec.Command("node", "build_night.mjs", "--task", "plan").Output()
if err != nil {
panic(err)
}
var input map[string]any
if err := json.Unmarshal(out, &input); err != nil {
panic(err)
}
// ... POST /run with the Idempotency-Key, poll jobs/{job_id}, unmarshal into `rep`.
top := map[string]bool{}
for _, t := range input["targets"].([]any) {
m := t.(map[string]any)
if m["priority"].(float64) == 1 && m["status"] != "unobservable" {
top[m["name"].(string)] = true
}
}
var lost []string
for _, d := range rep.Dropped {
if top[d.Target] {
lost = append(lost, d.Target)
}
}
for _, r := range rep.Schedule {
fmt.Printf(" %s-%s %s\n", r.Start, r.End, r.Target)
}
if rep.Verdict != "ready" || len(lost) > 0 {
fmt.Fprintf(os.Stderr, "observing-desk: verdict=%s priority-1 dropped=%s\n",
rep.Verdict, strings.Join(lost, ","))
os.Exit(1)
}
fmt.Println("observing-desk: ready")
// The gate, on top of the ObservingDesk client from section 2. Build tonight's
// request with your own ephemeris code - the night block, the targets, and for a
// run-sheet gate the draft_rows - POST /run with the Idempotency-Key, poll
// jobs/{job_id}, then:
//
// plan lane: fail unless verdict is "ready" and no observable priority-1
// target appears in dropped;
// check lane: fail when any finding has status "problem"; a "marginal" row is
// worth printing but is not usually a build failure.
//
// A truncated job is also a failure: the reply you hold is a prefix. Resubmit
// with a retry_note and an incremented attempt suffix on the Idempotency-Key.
var input = java.nio.file.Files.readString(java.nio.file.Path.of("night.json"));
var digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8));
var key = "observing-desk:" + java.util.HexFormat.of().formatHex(digest).substring(0, 16) + ":a1";
System.out.println("planning " + input.length() + " bytes of night, key " + key);
# tonight.rb - plan the standing target list for tonight, every afternoon.
# Reuses the `call` helper from section 2.
require "digest"
require "open3"
# build_night.mjs wraps sky.js and parse.js and prints the whole request object.
out, status = Open3.capture2("node", "build_night.mjs", "--task", "plan")
abort "observing-desk: could not build the night" unless status.success?
input = JSON.parse(out)
abort "observing-desk: nothing to plan" if input["targets"].empty?
key = "observing-desk:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
# ... POST /run with that Idempotency-Key, then poll jobs/{job_id} as in section 5.
plan = JSON.parse(job["output"]["output"])
top = input["targets"]
.select { |t| t["priority"] == 1 && t["status"] != "unobservable" }
.map { |t| t["name"] }
lost = plan["dropped"].map { |d| d["target"] } & top
puts plan["night_summary"]
plan["schedule"].each { |r| puts " #{r['start']}-#{r['end']} #{r['target']}" }
plan["risks"].each { |r| puts " risk: #{r}" }
abort "observing-desk: verdict=#{plan['verdict']} priority-1 dropped=#{lost.join(',')}" if
plan["verdict"] != "ready" || !lost.empty?
puts "observing-desk: ready"
<?php
// tonight.php - plan the standing target list for tonight, every afternoon.
// Reuses the `call` helper from section 2.
$out = shell_exec("node build_night.mjs --task plan");
$input = json_decode($out, true);
if (!$input || empty($input["targets"])) {
fwrite(STDERR, "observing-desk: nothing to plan\n");
exit(1);
}
$key = "observing-desk:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
// ... POST /run with that Idempotency-Key, then poll jobs/{job_id} as in section 5.
$plan = json_decode($job["output"]["output"], true);
$top = array_column(array_filter($input["targets"],
fn($t) => $t["priority"] === 1 && $t["status"] !== "unobservable"), "name");
$lost = array_intersect(array_column($plan["dropped"], "target"), $top);
echo $plan["night_summary"], PHP_EOL;
foreach ($plan["schedule"] as $r) {
printf(" %s-%s %s\n", $r["start"], $r["end"], $r["target"]);
}
if ($plan["verdict"] !== "ready" || $lost) {
fwrite(STDERR, "observing-desk: verdict=" . $plan["verdict"] .
" priority-1 dropped=" . (implode(",", $lost) ?: "none") . "\n");
exit(1);
}
echo "observing-desk: ready", PHP_EOL;
// The gate, on top of the ObservingDesk client from section 2.
var input = JsonSerializer.Deserialize<JsonElement>(File.ReadAllText("night.json"));
// ... POST /run with the Idempotency-Key, poll jobs/{job_id}, parse data.output.output.
var verdict = report.GetProperty("verdict").GetString();
var dropped = report.GetProperty("dropped").EnumerateArray()
.Select(d => d.GetProperty("target").GetString())
.ToHashSet();
var lost = input.GetProperty("targets").EnumerateArray()
.Where(t => t.GetProperty("priority").GetInt32() == 1
&& t.GetProperty("status").GetString() != "unobservable")
.Select(t => t.GetProperty("name").GetString())
.Where(n => dropped.Contains(n))
.ToList();
foreach (var r in report.GetProperty("schedule").EnumerateArray())
Console.WriteLine($" {r.GetProperty("start")}-{r.GetProperty("end")} {r.GetProperty("target")}");
if (verdict != "ready" || lost.Count > 0)
{
Console.Error.WriteLine($"observing-desk: verdict={verdict}, priority-1 dropped={string.Join(",", lost)}");
Environment.Exit(1);
}
Console.WriteLine("observing-desk: ready");
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.