Drive Season Tally from your own code
Everything the page does is available over HTTP. A natural use is a league or club that already keeps its box scores in a file or a database: a script rebuilds each team's sheet after every game day, asks for fresh coach's notes only for the players whose numbers moved, and at the end of the season asks for the award cases.
One thing to be clear about first: the model never does the arithmetic. The caller computes the sheet — per-game rates, percentages, ranks, trends, pass marks and the team summary under every column — and sends those numbers as facts. The model writes notes, flags and award cases and cites every number it uses; the caller checks each cited number against its own sheet. The page's own sheet engine and checker ship as plain scripts you can load in node — see the end of the examples.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"season-tally"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object: never wrap it as {"input": {…}} — that returns 200, warns unknown field 'input', and hides every field from the model.
Errors:
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: notes and award cases are metered and need a personal token. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
One system prompt serves three tasks. task picks the contract; a missing or unknown value is read as notes and the reply's headline says so.
| task | What it does | What comes back |
|---|---|---|
notes | The end-of-season reading of the whole sheet: a coach's note for every player row sent, written for the player and their parents. | One entry in rows per row in table, in table order (note, strength, focus, cites, 0 to 3 flags), 0 to 6 team anomalies, 2 to 4 team_next items, and skipped for a row with every stat blank. |
renote | Some numbers changed since the last notes (a late game, a fixed typo, a new race). Send only the changed rows; every other note stands. | The notes shape with "task": "renote": a fresh note for each row in changed_rows only, anomalies about those rows, 0 to 2 team_next items. Merge them over the previous notes yourself (Verdict.merge). |
awards | Decide the season awards you name, in order, from the sheet and the coach's accepted notes. Effort or attitude awards are decided from the notes. | One case per award in awards (winner, runner-up, case, counter, verdict, cites) and notes for close calls and awards the sheet cannot decide. |
The input fields
Every run body is a JSON object of string fields. task, team, sport and context are plain text; every other field is a JSON-encoded string — build the structure, then JSON.stringify it into the field. The declared input schema requires task, team, columns, table and summary (/estimate warns when one is missing); the page always sends task, team, sport, context, columns, table, summary and checks, plus the lane's own fields.
| Field | What it holds |
|---|---|
task | "notes", "renote" or "awards". |
team | The sheet's title, e.g. Quarrymead Hawks U14 girls - winter season (the page cuts it at 120 characters). |
sport | The sport and level, e.g. Basketball, U14 club league, 14 games (60 characters). |
context | The coach's own words about the season: sample sizes, injuries, who joined late, what the team worked on. It outranks the model's assumptions. The page cuts the middle past 1,200 characters. |
columns | JSON string: [{"key":"M","name":"PPG","kind":"derived","rule":"=D / B","rule_named":"{PTS} / {GP}","better":"higher","format":"number","threshold":60}]. kind is player, number, text or derived; a derived column carries its rule by letter and by name. better is higher or lower (race times). format is number, percent (45.4 means 45.4%), time m:ss or text. threshold is the coach's pass mark, a number or an m:ss string. A rank column adds "note": "rank 1 is best; ties share a rank". At most 26 columns. |
table | JSON string: {"header":["row","Player","GP",...],"rows":[["r1","Maya Quellin",14,...]]}. Each row starts with its row id. A value is a number, an m:ss string, text, null for a blank cell, or an error code such as #DIV/0. At most 40 rows; when more were cut, cut says so in a sentence and the model is told to say nothing about rows it was not given. |
summary | JSON string: {"rows_in_sheet":10,"columns":{"FT%":{"n":10,"blanks":0,"avg":56.6,"median":58.6,"min":25,"max":80,"best_row":"r8","threshold":60,"meets":5}}}, the team numbers for every numeric column, in the cells' format (total only on plain number columns). |
checks | JSON string: an array of plain-English facts found before the run (cells that are not numbers, outliers past two standard deviations, pass marks nobody or everybody meets, trends running the wrong way, blanks). At most 30. The model uses them and must not contradict them. |
changed_rows | renote only. JSON string: ["r4","r5","r8"], the rows whose numbers changed. table then holds only these rows; summary is still the whole team. |
awards | awards only. JSON string: [{"name":"MVP","hint":"most valuable over the whole season"}], up to 8, in order. The hint picks the columns that matter. |
notes | awards only. JSON string: {"r1":"Maya ran our offence all winter...","r2":"..."}, the coach's accepted notes (400 characters each, about 6,000 in all). They are the only evidence for an effort or attitude award. |
retry_note | Optional plain text, sent only when the previous reply could not be read, telling the model what to fix. |
The reply contract
The reply text is one JSON object, no Markdown and no code fences. Every array is present, empty when there is nothing to put in it. notes and renote share one shape:
{
"task": "notes | renote",
"headline": "one sentence on the team's season, grounded in the summary",
"rows": [{
"row": "r1",
"note": "2 to 4 sentences for the end-of-season report",
"strength": "a few words", "focus": "one concrete thing to work on, tied to a column",
"cites": [{ "col": "PPG", "value": 12.0 }, { "col": "PPG", "stat": "team_avg", "value": 5.9 }],
"flags": [{ "col": "FT%", "kind": "high|low|up|down|check", "text": "under 12 words" }]
}],
"anomalies": [{ "kind": "column|row|data", "col": "FT%", "rows": ["r5"], "text": "...", "cites": [...] }],
"team_next": ["2 to 4 items for notes, 0 to 2 for renote, each tied to a column"],
"skipped": [{ "row": "r9", "reason": "why no note was possible" }]
}
awards:
{
"task": "awards",
"headline": "one sentence on how the awards fall",
"awards": [{
"award": "MVP", "winner": "r1", "runner_up": "r2 or null",
"case": "3 to 5 sentences with numbers from the sheet",
"counter": "the strongest case for the runner-up or against the winner",
"verdict": "why the winner holds",
"cites": [{ "row": "r1", "col": "Eff/G", "value": 13.4 }, { "col": "Eff/G", "stat": "team_avg", "value": 6.6 }]
}],
"notes": ["close calls, ties, an award the sheet cannot decide"]
}
A cite is {col, value} plus an optional stat — value (the default: the row's own cell), team_avg, team_median, team_min, team_max, team_total, threshold, meets or filled — and an optional row when the cell belongs to another row (always in award cases). value is a number, or an m:ss string for times. Players are named by row id in row, rows, winner and runner_up, and by name in prose.
What the page keeps when it reads a reply (Verdict.parseResult, then Verdict.normalize): it strips stray code fences and reads from the first { to the last }; an unknown flag kind becomes check, an unknown anomaly kind data, an unknown stat value; a row without a note, a repeated row, and an award without a winner are dropped; text is capped (a note at 900 characters, a case at 1,600). A reply that is not JSON at all is shown raw and the sheet is left untouched.
How the page checks the numbers
Every number the model writes must already be on the sheet: a cell, a team summary value or a pass mark. Verdict.reconcile holds it to that:
- Each cite is looked up: the column must exist and be numeric;
valuereads the row's own cell (the note's row, or the award winner when norowis given);team_*reads the summary;thresholdthe pass mark;meetsandfilledthe counts. It matches within the rounding the grid shows, or rounded more coarsely (12 for 12.0, 45% for 45.4%). A percentage may be cited as a fraction (0.454) and a time within half a second, asm:ssor seconds. A mismatch is marked red with what the sheet says. - Numbers in the prose (note, strength, focus) are matched against that row's cells, every team summary, the pass marks, numbers inside column names and the row count; for award cases, the headline, anomalies and
team_next, against every cell on the sheet. A number that matches nothing is listed as a warning. - Rows: a row that was sent but got no note is listed as "no comment yet"; a note or a winner for a row id that is not on the sheet is dropped; a flag on a column that does not exist is ignored.
- After a re-note, a note on a row whose numbers changed is shown faded as stale, and an unchanged note quoting a team figure that has since moved is reported as drift rather than as an error.
Worked examples
These are bundled examples on the page: the exact run body Sheet.buildInput builds and a reply produced against the shipped prompt (the page replays the same replies for free). The structured fields are shown decoded, one item per line.
notes: a U14 basketball team, the whole sheet
Input (ten players, five derived columns, a 60% pass mark on FT%):
{
"task": "notes",
"team": "Quarrymead Hawks U14 girls - winter season",
"sport": "Basketball, U14 club league, 14 games",
"context": "Fourteen-game winter league, 32-minute games. Box scores kept by parent volunteers, so steals and turnovers are approximate. Hana joined in January. We went 9-5 and lost the semi-final; free throws let us down in close games. Notes go home with the end-of-season report.",
"columns": "(a JSON string - decoded below)",
"table": "(a JSON string - decoded below)",
"summary": "(a JSON string - decoded below)",
"checks": "(a JSON string - decoded below)"
}
columns, decoded:
[
{"key": "A", "name": "Player", "kind": "player"},
{"key": "B", "name": "GP", "kind": "number", "better": "higher", "format": "number"},
{"key": "C", "name": "MIN", "kind": "number", "better": "higher", "format": "number"},
{"key": "D", "name": "PTS", "kind": "number", "better": "higher", "format": "number"},
{"key": "E", "name": "REB", "kind": "number", "better": "higher", "format": "number"},
{"key": "F", "name": "AST", "kind": "number", "better": "higher", "format": "number"},
{"key": "G", "name": "STL", "kind": "number", "better": "higher", "format": "number"},
{"key": "H", "name": "TOV", "kind": "number", "better": "lower", "format": "number"},
{"key": "I", "name": "FGM", "kind": "number", "better": "higher", "format": "number"},
{"key": "J", "name": "FGA", "kind": "number", "better": "higher", "format": "number"},
{"key": "K", "name": "FTM", "kind": "number", "better": "higher", "format": "number"},
{"key": "L", "name": "FTA", "kind": "number", "better": "higher", "format": "number"},
{"key": "M", "name": "PPG", "kind": "derived", "rule": "=D / B", "rule_named": "{PTS} / {GP}", "better": "higher", "format": "number"},
{"key": "N", "name": "FG%", "kind": "derived", "rule": "=I / J * 100", "rule_named": "{FGM} / {FGA} * 100", "better": "higher", "format": "percent"},
{"key": "O", "name": "FT%", "kind": "derived", "rule": "=K / L * 100", "rule_named": "{FTM} / {FTA} * 100", "better": "higher", "format": "percent", "threshold": 60},
{"key": "P", "name": "AST/TO", "kind": "derived", "rule": "=F / H", "rule_named": "{AST} / {TOV}", "better": "higher", "format": "number"},
{"key": "Q", "name": "Eff/G", "kind": "derived", "rule": "=(D + E + F + G-H-(J-I)-(L-K)) / B", "rule_named": "({PTS} + {REB} + {AST} + {STL}-{TOV}-({FGA}-{FGM})-({FTA}-{FTM})) / {GP}", "better": "higher", "format": "number"},
{"key": "R", "name": "Eff rank", "kind": "derived", "rule": "=RANK(Q)", "rule_named": "RANK({Eff/G})", "better": "lower", "format": "number", "note": "rank 1 is best; ties share a rank"}
]
table, decoded:
{"header": ["row", "Player", "GP", "MIN", "PTS", "REB", "AST", "STL", "TOV", "FGM", "FGA", "FTM", "FTA", "PPG", "FG%", "FT%", "AST/TO", "Eff/G", "Eff rank"],
"rows": [
["r1", "Maya Quellin", 14, 312, 168, 52, 61, 25, 30, 64, 141, 32, 44, 12, 45.4, 72.7, 2.03, 13.4, 1],
["r2", "Jordan Brakewell", 14, 298, 142, 98, 18, 12, 22, 58, 120, 22, 41, 10.1, 48.3, 53.7, 0.82, 11.9, 2],
["r3", "Priya Venkatessil", 13, 240, 96, 40, 44, 30, 19, 38, 102, 14, 22, 7.4, 37.3, 63.6, 2.32, 9.2, 3],
["r4", "Sofia Marquenta", 14, 265, 121, 71, 20, 9, 35, 47, 126, 25, 38, 8.6, 37.3, 65.8, 0.57, 6.7, 5],
["r5", "Keisha Thornbeck", 12, 180, 64, 66, 9, 11, 14, 27, 60, 8, 21, 5.3, 45, 38.1, 0.64, 7.5, 4],
["r6", "Emma Krazowin", 14, 204, 58, 30, 38, 21, 16, 22, 68, 10, 14, 4.1, 32.4, 71.4, 2.38, 5.8, 6],
["r7", "Lily Obasunde", 9, 96, 31, 28, 4, 6, 9, 13, 33, 5, 11, 3.4, 39.4, 45.5, 0.44, 3.8, 8],
["r8", "Zoe Pellinor", 14, 150, 44, 22, 15, 14, 20, 17, 55, 8, 10, 3.1, 30.9, 80, 0.75, 2.5, 9],
["r9", "Hana Sakurome", 6, 48, 10, 9, 3, 2, 5, 4, 15, 2, 4, 1.7, 26.7, 50, 0.6, 1, 10],
["r10", "Ava Tamberlin", 13, 133, 37, 45, 7, 8, 12, 16, 38, 3, 12, 2.8, 42.1, 25, 0.58, 4.2, 7]]}
summary, decoded:
{"rows_in_sheet": 10,
"columns": {
"GP": {"n": 10, "blanks": 0, "avg": 12.3, "median": 13.5, "min": 6, "max": 14, "total": 123, "best_row": "r1"},
"MIN": {"n": 10, "blanks": 0, "avg": 192.6, "median": 192, "min": 48, "max": 312, "total": 1926, "best_row": "r1"},
"PTS": {"n": 10, "blanks": 0, "avg": 77.1, "median": 61, "min": 10, "max": 168, "total": 771, "best_row": "r1"},
"REB": {"n": 10, "blanks": 0, "avg": 46.1, "median": 42.5, "min": 9, "max": 98, "total": 461, "best_row": "r2"},
"AST": {"n": 10, "blanks": 0, "avg": 21.9, "median": 16.5, "min": 3, "max": 61, "total": 219, "best_row": "r1"},
"STL": {"n": 10, "blanks": 0, "avg": 13.8, "median": 11.5, "min": 2, "max": 30, "total": 138, "best_row": "r3"},
"TOV": {"n": 10, "blanks": 0, "avg": 18.2, "median": 17.5, "min": 5, "max": 35, "total": 182, "best_row": "r9"},
"FGM": {"n": 10, "blanks": 0, "avg": 30.6, "median": 24.5, "min": 4, "max": 64, "total": 306, "best_row": "r1"},
"FGA": {"n": 10, "blanks": 0, "avg": 75.8, "median": 64, "min": 15, "max": 141, "total": 758, "best_row": "r1"},
"FTM": {"n": 10, "blanks": 0, "avg": 12.9, "median": 9, "min": 2, "max": 32, "total": 129, "best_row": "r1"},
"FTA": {"n": 10, "blanks": 0, "avg": 21.7, "median": 17.5, "min": 4, "max": 44, "total": 217, "best_row": "r1"},
"PPG": {"n": 10, "blanks": 0, "avg": 5.9, "median": 4.7, "min": 1.7, "max": 12, "best_row": "r1"},
"FG%": {"n": 10, "blanks": 0, "avg": 38.5, "median": 38.3, "min": 26.7, "max": 48.3, "best_row": "r2"},
"FT%": {"n": 10, "blanks": 0, "avg": 56.6, "median": 58.6, "min": 25, "max": 80, "best_row": "r8", "threshold": 60, "meets": 5},
"AST/TO": {"n": 10, "blanks": 0, "avg": 1.11, "median": 0.7, "min": 0.44, "max": 2.38, "best_row": "r6"},
"Eff/G": {"n": 10, "blanks": 0, "avg": 6.6, "median": 6.3, "min": 1, "max": 13.4, "best_row": "r1"},
"Eff rank": {"n": 10, "blanks": 0, "avg": 6, "median": 6, "min": 1, "max": 10, "best_row": "r1"}}}
checks, decoded:
[
"Hana Sakurome's GP of 6 is well behind the team (average 12.3)",
"Jordan Brakewell's REB of 98 is well ahead of the team (average 46.1)",
"Maya Quellin's AST of 61 is well ahead of the team (average 21.9)"
]
Reply (the first 2 of 10 rows; the rest of the reply whole):
{
"task": "notes",
"headline": "A balanced Hawks side shared the load with 771 points across the roster, but a team free-throw average of 56.6% sat under the 60% mark, which is where the close games slipped away.",
"rows": [
{
"row": "r1",
"note": "Maya ran our offence all winter: 12.0 points a game against a team average of 5.9, and 61 assists against a team average of 21.9. Her 72.7% at the free-throw line was a steadying hand late in games, and her 13.4 efficiency per game (a one-number total of everything good minus misses and turnovers) led the team. Next season, aim to bring the 30 turnovers down, since she has the ball more than anyone.",
"strength": "playmaking and scoring",
"focus": "Fewer turnovers (TOV) while still running the offence",
"cites": [
{"col": "PPG", "value": 12.0},
{"col": "PPG", "stat": "team_avg", "value": 5.9},
{"col": "AST", "value": 61},
{"col": "AST", "stat": "team_avg", "value": 21.9},
{"col": "FT%", "value": 72.7},
{"col": "Eff/G", "value": 13.4},
{"col": "TOV", "value": 30}
],
"flags": [
{"col": "AST", "kind": "high", "text": "Far ahead of the team average"},
{"col": "Eff/G", "kind": "high", "text": "Team-best efficiency per game"}
]
},
{
"row": "r2",
"note": "Jordan owned the glass: 98 rebounds against a team average of 46.1, and she made the most of her shots with a team-best 48.3% from the field. She added 10.1 points a game across all 14 games. Free throws are the next step - 53.7% sits just under our 60% mark, and closing that gap turns her hard work inside into points.",
"strength": "rebounding and finishing inside",
"focus": "Free throws (FT%) up past the 60% mark",
"cites": [
{"col": "REB", "value": 98},
{"col": "REB", "stat": "team_avg", "value": 46.1},
{"col": "FG%", "value": 48.3},
{"col": "PPG", "value": 10.1},
{"col": "GP", "value": 14},
{"col": "FT%", "value": 53.7}
],
"flags": [
{"col": "REB", "kind": "high", "text": "Far ahead of the team average"},
{"col": "FT%", "kind": "low", "text": "Just under the 60% mark"}
]
}
],
"anomalies": [
{
"kind": "column",
"col": "FT%",
"rows": ["r2", "r5", "r7", "r9", "r10"],
"text": "Only 5 of 10 players reached the 60% free-throw mark and the team average is 56.6%; several of our strongest rebounders are among those under it, which matches the close-game losses the coach describes.",
"cites": [{"col": "FT%", "stat": "team_avg", "value": 56.6}]
},
{
"kind": "row",
"col": "REB",
"rows": ["r2"],
"text": "Jordan Brakewell's 98 rebounds are the team maximum and far above the team average of 46.1 - the team's second-chance offence leaned heavily on one player.",
"cites": [{"col": "REB", "stat": "team_max", "value": 98}, {"col": "REB", "stat": "team_avg", "value": 46.1}]
},
{
"kind": "row",
"col": "AST",
"rows": ["r1"],
"text": "Maya Quellin's 61 assists dwarf the team average of 21.9; much of the offence ran through one ball handler, so developing a second creator would spread the load.",
"cites": [{"col": "AST", "stat": "team_max", "value": 61}, {"col": "AST", "stat": "team_avg", "value": 21.9}]
},
{
"kind": "data",
"col": "TOV",
"rows": [],
"text": "Steals and turnovers were kept by parent volunteers and are approximate, so treat STL, TOV and AST/TO as rough guides rather than exact counts.",
"cites": []
},
{
"kind": "row",
"col": "GP",
"rows": ["r9"],
"text": "Hana Sakurome joined in January and played 6 games against a team average of 12.3, so her rates rest on a small sample.",
"cites": [{"col": "GP", "stat": "team_min", "value": 6}, {"col": "GP", "stat": "team_avg", "value": 12.3}]
}
],
"team_next": [
"Free throws (FT%): lift the team average of 56.6% past the 60% mark with pressure free throws at the end of every practice.",
"Turnovers (TOV): the team committed 182 - work on passing under pressure so more possessions end in a shot.",
"Field-goal shooting (FG%): the team median is 38.3%, so finishing drills around the basket will turn our rebounds into points.",
"Share the playmaking (AST): grow a second ball handler to support the team's assist leader."
],
"skipped": []
}
The page's check of this reply against the sheet: Every one of the 10 rows sent got a note. All 66 cited numbers match the sheet.
renote: cross-country after the last race, three rows changed
Input (only the three runners whose times changed are in table; summary is the whole team of eight):
{
"task": "renote",
"team": "Thornacre cross-country - autumn",
"sport": "Cross-country running, high-school 5 km, 5 races",
"context": "Five 5 km races this autumn on different courses (race 3 was the hilly one). Times are chip times. Eli was ill for race 3. The county qualifying standard is 21:00, and the top five count for the team score.",
"columns": "(a JSON string - decoded below)",
"table": "(a JSON string - decoded below)",
"summary": "(a JSON string - decoded below)",
"checks": "(a JSON string - decoded below)",
"changed_rows": "(a JSON string - decoded below)"
}
columns, decoded:
[
{"key": "A", "name": "Runner", "kind": "player"},
{"key": "B", "name": "Race 1", "kind": "number", "better": "lower", "format": "time m:ss"},
{"key": "C", "name": "Race 2", "kind": "number", "better": "lower", "format": "time m:ss"},
{"key": "D", "name": "Race 3", "kind": "number", "better": "lower", "format": "time m:ss"},
{"key": "E", "name": "Race 4", "kind": "number", "better": "lower", "format": "time m:ss"},
{"key": "F", "name": "Race 5", "kind": "number", "better": "lower", "format": "time m:ss"},
{"key": "G", "name": "Best", "kind": "derived", "rule": "=MIN(B:F)", "rule_named": "MIN({Race 1}:{Race 5})", "better": "lower", "format": "time m:ss", "threshold": "21:00"},
{"key": "H", "name": "Average", "kind": "derived", "rule": "=AVG(B:F)", "rule_named": "AVG({Race 1}:{Race 5})", "better": "lower", "format": "time m:ss"},
{"key": "I", "name": "Trend s/race", "kind": "derived", "rule": "=TREND(B:F)", "rule_named": "TREND({Race 1}:{Race 5})", "better": "lower", "format": "number"},
{"key": "J", "name": "Team rank", "kind": "derived", "rule": "=RANKASC(G)", "rule_named": "RANKASC({Best})", "better": "lower", "format": "number", "note": "rank 1 is best; ties share a rank"},
{"key": "K", "name": "Qualifies", "kind": "derived", "rule": "=IF(G <= 21:00; \"Yes\"; \"No\")", "rule_named": "IF({Best} <= 21:00; \"Yes\"; \"No\")", "format": "text"}
]
table, decoded:
{"header": ["row", "Runner", "Race 1", "Race 2", "Race 3", "Race 4", "Race 5", "Best", "Average", "Trend s/race", "Team rank", "Qualifies"],
"rows": [
["r4", "Dev Shahmeer", "20:30", "20:44", "20:59", "21:10", "21:25", "20:30", "20:58", 13.6, 3, "Yes"],
["r5", "Eli Brandessa", "24:05", "23:30", null, "22:58", "22:40", "22:40", "23:18", -20.2, 7, "No"],
["r8", "Hal Beckerling", "25:30", "25:05", "24:44", "24:20", "24:01", "24:01", "24:44", -22.3, 8, "No"]]}
summary, decoded:
{"rows_in_sheet": 8,
"columns": {
"Race 1": {"n": 8, "blanks": 0, "avg": "22:08", "median": "21:50", "min": "19:55", "max": "25:30", "best_row": "r2"},
"Race 2": {"n": 8, "blanks": 0, "avg": "21:53", "median": "21:32", "min": "19:48", "max": "25:05", "best_row": "r2"},
"Race 3": {"n": 7, "blanks": 1, "avg": "21:32", "median": "20:59", "min": "19:58", "max": "24:44", "best_row": "r7"},
"Race 4": {"n": 8, "blanks": 0, "avg": "21:39", "median": "21:16", "min": "19:41", "max": "24:20", "best_row": "r2"},
"Race 5": {"n": 8, "blanks": 0, "avg": "21:25", "median": "21:15", "min": "19:37", "max": "24:01", "best_row": "r2"},
"Best": {"n": 8, "blanks": 0, "avg": "21:18", "median": "20:48", "min": "19:37", "max": "24:01", "best_row": "r2", "threshold": "21:00", "meets": 4},
"Average": {"n": 8, "blanks": 0, "avg": "21:46", "median": "21:17", "min": "19:49", "max": "24:44", "best_row": "r2"},
"Trend s/race": {"n": 8, "blanks": 0, "avg": -9.9, "median": -12.3, "min": -22.3, "max": 13.6, "best_row": "r8"},
"Team rank": {"n": 8, "blanks": 0, "avg": 4, "median": 4, "min": 1, "max": 8, "best_row": "r2"}}}
checks, decoded:
[
"Hal Beckerling's Race 3 of 24:44 is well behind the team (average 21:32, lower is better)",
"Race 3 is blank for 1 of 8 rows",
"Dev Shahmeer's Trend s/race of 13.6 is well behind the team (average -9.9, lower is better)",
"Dev Shahmeer's Trend s/race runs the wrong way (13.6 per step)"
]
changed_rows, decoded:
[
"r4",
"r5",
"r8"
]
Reply (the first 2 of 3 rows; the rest of the reply whole):
{
"task": "renote",
"headline": "With Race 5 times now in for Dev, Eli and Hal, the Race 5 column is complete and the team average Trend s/race stands at -9.9.",
"rows": [
{
"row": "r4",
"note": "Dev's Race 1 of 20:30 is still his Best, under the 21:00 county standard and good for rank 3. His Race 5 of 21:25 continues the pattern of each race being a little slower than the one before, so his Trend s/race - seconds gained or lost each race, where negative means faster - is 13.6 against a team average of -9.9. Holding his early-season pace through the autumn is the clear focus for next year.",
"strength": "20:30 opener, under the county standard",
"focus": "turn his Trend s/race from slowing to improving",
"cites": [
{"col": "Race 1", "value": "20:30"},
{"col": "Best", "value": "20:30"},
{"col": "Team rank", "value": 3},
{"col": "Race 5", "value": "21:25"},
{"col": "Trend s/race", "value": 13.6},
{"col": "Trend s/race", "stat": "team_avg", "value": -9.9}
],
"flags": [
{"col": "Trend s/race", "kind": "down", "text": "times rose race on race"},
{"col": "Race 5", "kind": "low", "text": "slowest race of his season"},
{"col": "Best", "kind": "high", "text": "under the 21:00 county standard"}
]
},
{
"row": "r5",
"note": "Eli finished strongly: his Race 5 of 22:40 is a new Best, the last step in a run from 24:05 in Race 1. His Trend s/race of -20.2 is far better than the team average of -9.9, though it comes from a season with the hilly Race 3 missed through illness. A full, healthy season is the next step to build on that 22:40.",
"strength": "new best of 22:40 in the final race",
"focus": "race a full season to build on his 22:40 Best",
"cites": [
{"col": "Race 5", "value": "22:40"},
{"col": "Best", "value": "22:40"},
{"col": "Race 1", "value": "24:05"},
{"col": "Trend s/race", "value": -20.2},
{"col": "Trend s/race", "stat": "team_avg", "value": -9.9}
],
"flags": [
{"col": "Best", "kind": "up", "text": "new best in Race 5"},
{"col": "Race 3", "kind": "check", "text": "blank - ill for this race"}
]
}
],
"anomalies": [
{
"kind": "row",
"col": "Trend s/race",
"rows": ["r4"],
"text": "Dev Shahmeer's Race 5 confirms the slide rather than reversing it - he remains the only runner getting slower across the season, worth a conversation about training load or health.",
"cites": [
{"col": "Trend s/race", "row": "r4", "value": 13.6},
{"col": "Trend s/race", "stat": "team_avg", "value": -9.9}
]
},
{
"kind": "column",
"col": "Race 5",
"rows": ["r4", "r5", "r8"],
"text": "With these three times added, Race 5 now has a time for every runner, so its team average of 21:25 can be compared fairly with the earlier races.",
"cites": [
{"col": "Race 5", "stat": "team_avg", "value": "21:25"},
{"col": "Race 5", "stat": "team_max", "value": "24:01"}
]
}
],
"team_next": ["Trend s/race: keep the team-wide improvement going and catch early any runner whose times start rising."],
"skipped": []
}
The page's check of this reply against the sheet: Every one of the 3 rows sent got a note. All 22 cited numbers match the sheet.
awards: the same basketball team, three awards
Input (the sheet as in the notes example, plus the awards and the coach's accepted notes):
{
"task": "awards",
"team": "Quarrymead Hawks U14 girls - winter season",
"sport": "Basketball, U14 club league, 14 games",
"context": "Fourteen-game winter league, 32-minute games. Box scores kept by parent volunteers, so steals and turnovers are approximate. Hana joined in January. We went 9-5 and lost the semi-final; free throws let us down in close games. Notes go home with the end-of-season report.",
"columns": "(a JSON string - decoded below)",
"table": "(a JSON string - decoded below)",
"summary": "(a JSON string - decoded below)",
"checks": "(a JSON string - decoded below)",
"awards": "(a JSON string - decoded below)",
"notes": "(a JSON string - decoded below)"
}
columns, decoded:
[
{"key": "A", "name": "Player", "kind": "player"},
{"key": "B", "name": "GP", "kind": "number", "better": "higher", "format": "number"},
{"key": "C", "name": "MIN", "kind": "number", "better": "higher", "format": "number"},
{"key": "D", "name": "PTS", "kind": "number", "better": "higher", "format": "number"},
{"key": "E", "name": "REB", "kind": "number", "better": "higher", "format": "number"},
{"key": "F", "name": "AST", "kind": "number", "better": "higher", "format": "number"},
{"key": "G", "name": "STL", "kind": "number", "better": "higher", "format": "number"},
{"key": "H", "name": "TOV", "kind": "number", "better": "lower", "format": "number"},
{"key": "I", "name": "FGM", "kind": "number", "better": "higher", "format": "number"},
{"key": "J", "name": "FGA", "kind": "number", "better": "higher", "format": "number"},
{"key": "K", "name": "FTM", "kind": "number", "better": "higher", "format": "number"},
{"key": "L", "name": "FTA", "kind": "number", "better": "higher", "format": "number"},
{"key": "M", "name": "PPG", "kind": "derived", "rule": "=D / B", "rule_named": "{PTS} / {GP}", "better": "higher", "format": "number"},
{"key": "N", "name": "FG%", "kind": "derived", "rule": "=I / J * 100", "rule_named": "{FGM} / {FGA} * 100", "better": "higher", "format": "percent"},
{"key": "O", "name": "FT%", "kind": "derived", "rule": "=K / L * 100", "rule_named": "{FTM} / {FTA} * 100", "better": "higher", "format": "percent", "threshold": 60},
{"key": "P", "name": "AST/TO", "kind": "derived", "rule": "=F / H", "rule_named": "{AST} / {TOV}", "better": "higher", "format": "number"},
{"key": "Q", "name": "Eff/G", "kind": "derived", "rule": "=(D + E + F + G-H-(J-I)-(L-K)) / B", "rule_named": "({PTS} + {REB} + {AST} + {STL}-{TOV}-({FGA}-{FGM})-({FTA}-{FTM})) / {GP}", "better": "higher", "format": "number"},
{"key": "R", "name": "Eff rank", "kind": "derived", "rule": "=RANK(Q)", "rule_named": "RANK({Eff/G})", "better": "higher", "format": "number"}
]
table, decoded:
{"header": ["row", "Player", "GP", "MIN", "PTS", "REB", "AST", "STL", "TOV", "FGM", "FGA", "FTM", "FTA", "PPG", "FG%", "FT%", "AST/TO", "Eff/G", "Eff rank"],
"rows": [
["r1", "Maya Quellin", 14, 312, 168, 52, 61, 25, 30, 64, 141, 32, 44, 12, 45.4, 72.7, 2.03, 13.4, 1],
["r2", "Jordan Brakewell", 14, 298, 142, 98, 18, 12, 22, 58, 120, 22, 41, 10.1, 48.3, 53.7, 0.82, 11.9, 2],
["r3", "Priya Venkatessil", 13, 240, 96, 40, 44, 30, 19, 38, 102, 14, 22, 7.4, 37.3, 63.6, 2.32, 9.2, 3],
["r4", "Sofia Marquenta", 14, 265, 121, 71, 20, 9, 35, 47, 126, 25, 38, 8.6, 37.3, 65.8, 0.57, 6.7, 5],
["r5", "Keisha Thornbeck", 12, 180, 64, 66, 9, 11, 14, 27, 60, 8, 21, 5.3, 45, 38.1, 0.64, 7.5, 4],
["r6", "Emma Krazowin", 14, 204, 58, 30, 38, 21, 16, 22, 68, 10, 14, 4.1, 32.4, 71.4, 2.38, 5.8, 6],
["r7", "Lily Obasunde", 9, 96, 31, 28, 4, 6, 9, 13, 33, 5, 11, 3.4, 39.4, 45.5, 0.44, 3.8, 8],
["r8", "Zoe Pellinor", 14, 150, 44, 22, 15, 14, 20, 17, 55, 8, 10, 3.1, 30.9, 80, 0.75, 2.5, 9],
["r9", "Hana Sakurome", 6, 48, 10, 9, 3, 2, 5, 4, 15, 2, 4, 1.7, 26.7, 50, 0.6, 1, 10],
["r10", "Ava Tamberlin", 13, 133, 37, 45, 7, 8, 12, 16, 38, 3, 12, 2.8, 42.1, 25, 0.58, 4.2, 7]]}
summary, decoded:
{"rows_in_sheet": 10,
"columns": {
"GP": {"n": 10, "blanks": 0, "avg": 12.3, "median": 13.5, "min": 6, "max": 14, "total": 123, "best_row": "r1"},
"MIN": {"n": 10, "blanks": 0, "avg": 192.6, "median": 192, "min": 48, "max": 312, "total": 1926, "best_row": "r1"},
"PTS": {"n": 10, "blanks": 0, "avg": 77.1, "median": 61, "min": 10, "max": 168, "total": 771, "best_row": "r1"},
"REB": {"n": 10, "blanks": 0, "avg": 46.1, "median": 42.5, "min": 9, "max": 98, "total": 461, "best_row": "r2"},
"AST": {"n": 10, "blanks": 0, "avg": 21.9, "median": 16.5, "min": 3, "max": 61, "total": 219, "best_row": "r1"},
"STL": {"n": 10, "blanks": 0, "avg": 13.8, "median": 11.5, "min": 2, "max": 30, "total": 138, "best_row": "r3"},
"TOV": {"n": 10, "blanks": 0, "avg": 18.2, "median": 17.5, "min": 5, "max": 35, "total": 182, "best_row": "r9"},
"FGM": {"n": 10, "blanks": 0, "avg": 30.6, "median": 24.5, "min": 4, "max": 64, "total": 306, "best_row": "r1"},
"FGA": {"n": 10, "blanks": 0, "avg": 75.8, "median": 64, "min": 15, "max": 141, "total": 758, "best_row": "r1"},
"FTM": {"n": 10, "blanks": 0, "avg": 12.9, "median": 9, "min": 2, "max": 32, "total": 129, "best_row": "r1"},
"FTA": {"n": 10, "blanks": 0, "avg": 21.7, "median": 17.5, "min": 4, "max": 44, "total": 217, "best_row": "r1"},
"PPG": {"n": 10, "blanks": 0, "avg": 5.9, "median": 4.7, "min": 1.7, "max": 12, "best_row": "r1"},
"FG%": {"n": 10, "blanks": 0, "avg": 38.5, "median": 38.3, "min": 26.7, "max": 48.3, "best_row": "r2"},
"FT%": {"n": 10, "blanks": 0, "avg": 56.6, "median": 58.6, "min": 25, "max": 80, "best_row": "r8", "threshold": 60, "meets": 5},
"AST/TO": {"n": 10, "blanks": 0, "avg": 1.11, "median": 0.7, "min": 0.44, "max": 2.38, "best_row": "r6"},
"Eff/G": {"n": 10, "blanks": 0, "avg": 6.6, "median": 6.3, "min": 1, "max": 13.4, "best_row": "r1"},
"Eff rank": {"n": 10, "blanks": 0, "avg": 6, "median": 6, "min": 1, "max": 10, "best_row": "r9"}}}
checks, decoded:
[
"Hana Sakurome's GP of 6 is well behind the team (average 12.3)",
"Jordan Brakewell's REB of 98 is well ahead of the team (average 46.1)",
"Maya Quellin's AST of 61 is well ahead of the team (average 21.9)"
]
awards, decoded:
[
{"name": "MVP", "hint": "most valuable over the whole season"},
{"name": "Best Defender", "hint": "steals, rebounds and protecting the ball"},
{"name": "Coach's Award", "hint": "effort, attitude and improvement the coach saw - use the notes"}
]
notes, decoded (each note shortened here):
{
"r1": "Maya ran our offence all winter: 12.0 points a game against a team average of 5.9, and 61 assists against a team average of 21.9. Her 72.7% at the...",
"r2": "Jordan owned the glass: 98 rebounds against a team average of 46.1, and she made the most of her shots with a team-best 48.3% from the field. She a...",
"r3": "Priya was our best defender on the ball with a team-high 30 steals, and she looked after it too: 2.32 assists for every turnover (AST/TO), well abo...",
"r4": "Sofia gave us size and scoring every night: 71 rebounds against a team average of 46.1, 8.6 points a game, and a reliable 65.8% at the free-throw l...",
"r5": "Keisha did a lot in 180 minutes: 66 rebounds, 45% shooting from the field against a team average of 38.5%, and the fourth-best efficiency per game...",
"r6": "Emma was our most careful ball handler: a team-best 2.38 assists for every turnover (AST/TO), with 38 assists and 21 steals across all 14 games. Sh...",
"r7": "Lily played 9 games and 96 minutes, so her numbers come from a smaller sample than most. In that time she shot 39.4% from the field, just above the...",
"r8": "Zoe was there for all 14 games and was our best free-throw shooter at 80% - exactly what we needed in close finishes. She also added 14 steals off...",
"r9": "Hana joined us in January, so 6 games and 48 minutes is a short first season, and her numbers should be read that way. She took good care of the ba...",
"r10": "Ava made her minutes count on the boards: 45 rebounds, right near the team average of 46.1 in 133 minutes, and 42.1% from the field, above the team..."
}
Reply:
{
"task": "awards",
"headline": "Maya Quellin takes MVP on the team's best all-round numbers, Priya Venkatessil's steals and ball security earn Best Defender, and the Coach's Award goes to Zoe Pellinor on the notes, with the coach to confirm.",
"awards": [
{
"award": "MVP",
"winner": "r1",
"runner_up": "r2",
"case": "Maya Quellin led the Hawks in efficiency at 13.4 per game against a team average of 6.6, the clearest single measure of all-round value on the sheet. She scored 12.0 points a game, the team maximum, against a team average of 5.9, and created for everyone else with 61 assists against a team average of 21.9. She did it across all 14 games and 312 minutes, and shot 72.7% at the line in a season where free throws hurt the team.",
"counter": "Jordan Brakewell has the strongest rival case: 98 rebounds, a team-best 48.3% from the field and 11.9 efficiency per game, and she turned the ball over 22 times to Maya's 30.",
"verdict": "Maya holds because she led the team in efficiency, scoring and assists at once, while Jordan's case rests mainly on rebounding.",
"cites": [
{"row": "r1", "col": "Eff/G", "value": 13.4},
{"col": "Eff/G", "stat": "team_avg", "value": 6.6},
{"row": "r1", "col": "PPG", "value": 12.0},
{"col": "PPG", "stat": "team_avg", "value": 5.9},
{"row": "r1", "col": "AST", "value": 61},
{"col": "AST", "stat": "team_avg", "value": 21.9},
{"row": "r1", "col": "GP", "value": 14},
{"row": "r1", "col": "MIN", "value": 312},
{"row": "r1", "col": "FT%", "value": 72.7},
{"row": "r2", "col": "REB", "value": 98},
{"row": "r2", "col": "FG%", "value": 48.3},
{"row": "r2", "col": "Eff/G", "value": 11.9},
{"row": "r2", "col": "TOV", "value": 22},
{"row": "r1", "col": "TOV", "value": 30}
]
},
{
"award": "Best Defender",
"winner": "r3",
"runner_up": "r2",
"case": "Priya Venkatessil had a team-high 30 steals against a team average of 13.8, the most direct defensive number on the sheet. She also protected the ball better than almost anyone, with 2.32 assists for every turnover (AST/TO) against a team average of 1.11 and 19 turnovers over 13 games. She added 40 rebounds, close to the team median of 42.5, so she was not just a gambler in passing lanes.",
"counter": "Jordan Brakewell anchored the defensive glass with 98 rebounds, the team maximum and far above the team average of 46.1, ending possessions that Priya's steals never reached.",
"verdict": "Priya holds because she leads on steals and ball security, two of the award's three measures, while Jordan leads on one.",
"cites": [
{"row": "r3", "col": "STL", "value": 30},
{"col": "STL", "stat": "team_avg", "value": 13.8},
{"row": "r3", "col": "AST/TO", "value": 2.32},
{"col": "AST/TO", "stat": "team_avg", "value": 1.11},
{"row": "r3", "col": "TOV", "value": 19},
{"row": "r3", "col": "GP", "value": 13},
{"row": "r3", "col": "REB", "value": 40},
{"col": "REB", "stat": "team_median", "value": 42.5},
{"row": "r2", "col": "REB", "value": 98},
{"col": "REB", "stat": "team_avg", "value": 46.1}
]
},
{
"award": "Coach's Award",
"winner": "r8",
"runner_up": "r9",
"case": "The accepted notes are the only evidence for this award, and they say little directly about effort or attitude. The best-supported case is Zoe Pellinor: her note says she \"was there for all 14 games\" and was \"exactly what we needed in close finishes\" as the team's best free-throw shooter at 80%. She also \"added 14 steals off the bench\", a contribution in limited minutes.",
"counter": "Hana Sakurome's note describes a player who \"joined us in January\" and \"took good care of the ball with only 5 turnovers, the fewest on the team\" - a strong first season in 6 games, though the sheet has no trend column to show improvement.",
"verdict": "Zoe holds on the notes as written, because her note ties a full season of availability to the team's biggest weakness, free throws.",
"cites": [
{"row": "r8", "col": "GP", "value": 14},
{"row": "r8", "col": "FT%", "value": 80},
{"row": "r8", "col": "STL", "value": 14},
{"row": "r9", "col": "TOV", "value": 5},
{"row": "r9", "col": "GP", "value": 6}
]
}
],
"notes": [
"Coach's Award: none of the accepted notes speaks directly to effort, attitude or improvement, and the sheet has no trend column, so Zoe Pellinor is the best-supported choice from the notes, not a decision the stats can make - the coach should decide.",
"Best Defender leans on steals and turnovers, which the context says were kept approximately by parent volunteers; if those counts are off, Jordan Brakewell's rebounding case gets stronger.",
"Jordan Brakewell is runner-up for both MVP and Best Defender without winning either - worth a mention in the season report."
]
}
The page's check of this reply against the sheet: Every winner and runner-up is a player on the sheet. All 29 cited numbers match the sheet.
The page does the arithmetic and checks the reply; the API does not
Over the API you get the model's reply as JSON. The numbers in table and summary are yours to compute, and the page's own code is plain scripts you can load in node after global.window = {}: sheet.js (Sheet.compute(sheet) for the derived columns and the team summary, Sheet.buildInput(computed, {task, rows, awards, notes}) for this exact run body, Sheet.rowSig to find the rows that changed) and verdict.js (Verdict.parseResult(text), Verdict.normalize(reply, task), Verdict.merge(previous, reply, changed_rows) and Verdict.reconcile(result, computed, {task, rows}) for the lines the page shows under the notes).
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. Paste your token from https://season-tally.skillsafe.ai/tokens.html in place of the placeholder.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://season-tally.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://season-tally.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// season-tally.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://season-tally.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://season-tally.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// SeasonTally.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class SeasonTally {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://season-tally.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://season-tally.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://season-tally.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://season-tally.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"season-tally"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "season-tally"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "season-tally" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "season-tally"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "season-tally"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "season-tally" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "season-tally"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "season-tally" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself (not wrapped in {"input": ...}); save one of the example inputs above as input.json to try it. hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "notes", "team": "...", "sport": "...", "context": "...", "columns": "[...]", "table": "{...}", "summary": "{...}", "checks": "[...]"}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "notes", "team": "...", "sport": "...", "context": "...", "columns": "[...]", "table": "{...}", "summary": "{...}", "checks": "[...]"}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task, team, sport, context, columns, table, summary, checks}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses season-tally:<task>:<inputHash>:a<attempt>, e.g. season-tally:renote:…:a1; any stable hash of the input works. The job's output.output is the reply text; truncated: true means the balance cut it short.
TASK=$(jq -r '.task' input.json) # notes, renote or awards
KEY="season-tally:$TASK:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "season-tally:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # one JSON object: the lane's reply contract
print(j["charged_credits"], j.get("truncated"), reply.get("headline"))
import { createHash } from "node:crypto";
const key = `season-tally:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "season-tally:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "season-tally:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "season-tally:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["headline"]].inspect
$key = "season-tally:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["headline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"season-tally:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the job with the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: season-tally:$(jq -r '.task' input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "season-tally:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `season-tally:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "season-tally:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "season-tally:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "season-tally:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: season-tally:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"season-tally:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);