← Season Tally / API
Tokens

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:

StatusCodeMeaning
400validation_errorThe body is not a JSON object.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: notes and award cases are metered and need a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. 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.

taskWhat it doesWhat comes back
notesThe 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.
renoteSome 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).
awardsDecide 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.

FieldWhat it holds
task"notes", "renote" or "awards".
teamThe sheet's title, e.g. Quarrymead Hawks U14 girls - winter season (the page cuts it at 120 characters).
sportThe sport and level, e.g. Basketball, U14 club league, 14 games (60 characters).
contextThe 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.
columnsJSON 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.
tableJSON 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.
summaryJSON 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).
checksJSON 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_rowsrenote only. JSON string: ["r4","r5","r8"], the rows whose numbers changed. table then holds only these rows; summary is still the whole team.
awardsawards only. JSON string: [{"name":"MVP","hint":"most valuable over the whole season"}], up to 8, in order. The hint picks the columns that matter.
notesawards 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_noteOptional 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:

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"}
}

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')

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}' 

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}' 

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}' 

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