← Invite Tiers / API
Tokens

Drive Invite Tiers from your own code

Everything the page does is available over HTTP. A natural use is a guest list you already keep in a spreadsheet or a planning tool - a wedding, a milestone birthday, a dinner, a child's party - where a script re-sorts the list into tiers whenever it changes and asks for a fresh review of the tensions: a broken venue cap, a couple split across tiers, a plus-one the brief does not allow.

The reviewer is the event-planner skill by @travisjneuman (budgets, venue capacity, attendee management, contingency), applied to one job: guest-list tiering. One thing to be clear about first: the model never does the arithmetic. The caller computes every tier's seats, expected seats and cost, the rules that are broken right now and the coverage by side and group, and sends those numbers as facts with the cards. The model returns tensions and concrete card moves; the caller checks every move against its own rules. The page's own board logic and reconciler 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":"invite-tiers"}), 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: reviews 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 two tasks. task picks the contract, and the reply echoes it.

taskWhenWhat the reply adds
reviewThe first look at a sorted board, or a full fresh look.Tensions (every broken rule raised, then misfits, splits, empty tiers, balance) and up to 10 card moves. No recheck block.
recheckThe person moved, added or edited cards (or changed rules) since a review.New tensions with fresh ids, only the moves still needed (never a dismissed one), and a recheck block sorting every earlier tension into resolved or still_open. Send previous and changes.

Every run body is a flat JSON object of string fields. The structured ones are JSON-encoded strings on the wire ("tiers": "[{\"id\":\"b1\",...}]"), never nested objects. Send every field the page sends; /estimate warns when a field the input schema requires is missing.

FieldWhat it holds
task"review" or "recheck".
eventThe event's name, e.g. Sam & Alex - barn wedding (120 characters at most).
contextThe brief in the host's own words: venue and seated capacity, per-head price, house rules for plus-ones, children and colleagues. Treated as binding. The page cuts the middle past 900 characters.
settingsJSON string: {"currency","per_head","sides":[...],"invited_totals":{"cards","seats","expected","cost"},"unsorted_cards","empty_tiers":[...]}. sides are the hosts' sides (the two partners at a wedding) and may be empty.
tiersJSON string: ordered [{"id":"b1","order":1,"name","invited":true|false,"rules":{...},"sums":{...}}], 2 to 6 tiers. rules may hold max_seats, max_invites, budget, cumulative (this tier plus every tier above: the venue cap), require_tags, exclude_tags. sums are {cards, seats, expected, cost, seats_with_tiers_above, cost_with_tiers_above}.
cardsPlain multi-line text: a ## Tier name [id] header per tier, then the unsorted pile ([pile]), one card per line as id | name | N seats | side X | group Y | tags a,b | extra N | likely N% | note .... Order inside a tier is the host's ranking. The page keeps it under 14,000 characters by cutting notes, then the far end of the pile, then long names.
linksJSON string: [{"a":"g28","b":"g30","kind":"together"|"not_both"}].
violationsJSON string: the rules broken right now, [{"key","tier","cards":[...],"text"}]. Complete and correct: the model raises each one.
coverageJSON string: {"sides":[{"side","invited_seats","total_seats","share_pct"}],"uncovered_groups":[...],"imbalance":null|...}.
previousrecheck only. JSON string: the earlier review compacted (Reader.compact) — tensions {id, kind, tier, cards, text} with text cut to 160 characters, and moves with what the host did: accepted, dismissed or not acted on.
changesrecheck only. JSON string from Board.diff(before, after): {"moves":[{"card","from","to"}],"added":[...],"removed":[...],"edited":[...],"rules":[tier ids],"links":bool,"settings":bool}.
retry_noteOptional. Only on a retry after a reply that could not be parsed: one sentence telling the model to reply with the bare JSON object. The page sends it with attempt 2 of the idempotency key.

The reply text is one JSON object (the same envelope for both tasks):

{
  "task": "review | recheck",
  "headline": "One sentence: the state of the sort and the single most important thing to fix.",
  "tensions": [{ "id": "t1", "kind": "rule|misfit|split|empty|balance|other", "tier": "b2 | pile | board",
                 "cards": ["g6"], "severity": "high|medium|low", "text": "..." }],
  "moves":    [{ "id": "m1", "card": "g31", "to": "b3", "swap_with": "g30" or null, "reason": "...", "fixes": ["t1"] }],
  "questions": ["up to 4 questions only the hosts can answer"],
  "recheck":  { "resolved": ["t2"], "still_open": ["t5"], "note": "..." }
}

recheck appears only for task: "recheck". Every card, to and swap_with is an id from the input; a swap_with card sits in the to tier and goes to the moved card's current tier. Sums are never in the reply: the model quotes the numbers you sent. Hold each move to your rules before you apply it (Board.checkMove, or Reader.reconcile for the whole reply).

Worked examples

These are the bundled examples on the page: the exact run body the page builds (with Board.buildInput) and an excerpt of a reply produced against the shipped prompt (the page replays the same replies for free). In each body, cards is elided as "..." and shown on its own below, and each JSON-string field is shown decoded.

review: a barn wedding, 5 seats over the venue cap

Input (two sides, a cumulative seat cap of 56 on the B list, three broken rules):

{
 "task": "review",
 "event": "Sam & Alex - barn wedding",
 "context": "Sam & Alex, a barn wedding in June. The barn seats 56 for the dinner and there is no standing or second-sitting option. £95 a head covers food and drink. We'd rather keep it intimate than squeeze. Adults-only, except the immediate family's own kids. Plus-ones only for people in a settled relationship. Alex's team is eight people - inviting one or two without the rest could be awkward.",
 "settings": "(a JSON string - decoded below)",
 "tiers": "(a JSON string - decoded below)",
 "cards": "...",
 "links": "(a JSON string - decoded below)",
 "violations": "(a JSON string - decoded below)",
 "coverage": "(a JSON string - decoded below)"
}

cards, as the text the model reads:

## A · Must invite [b1] - 15 cards, 31 seats, invited
g1 | Mum & Dad (Sam's) | 2 seats | side Sam | group family | tags immediate
g2 | Mum & Steve (Alex's) | 2 seats | side Alex | group family | tags immediate
g3 | Dad (Alex's) | 1 seat | side Alex | group family | tags immediate | note Doesn't drive after dark
g4 | Ellie & Jon + Mia and Leo | 4 seats | side Sam | group family | tags immediate,kids
g5 | Ben & Ruth | 2 seats | side Alex | group family | tags immediate
g7 | Nana Pat | 1 seat | side Alex | group family | tags immediate
g8 | Priya + Tom (maid of honour) | 2 seats | side Sam | group uni | tags wedding-party | likely 95%
g9 | Marcus + Leah (best man) | 2 seats | side Alex | group school | tags wedding-party | likely 95%
g10 | Aunt Carol & Uncle Rob | 2 seats | side Sam | group family
g11 | Uncle Dev & Aunt Meera + 3 cousins | 5 seats | side Alex | group family | tags kids | likely 80% | note Cousins are 6, 9 and 12
g13 | Jamie | 1 seat | side Alex | group school
g12 | Hannah & Chris | 2 seats | side Sam | group uni
g15 | Oliver & Grace | 2 seats | side Alex | group uni
g17 | Will & Freya (Sydney) | 2 seats | side Alex | group school | tags travelling | likely 35% | note Would fly in; said they'd try
g37 | Grandpa Ken | 1 seat | side Alex | group family | tags immediate | likely 80% | note Lives in a care home; needs a carer with him
## B · If there's room [b2] - 18 cards, 30 seats, invited
g6 | Grandma Joan | 1 seat | side Sam | group family | tags immediate | likely 70% | note Needs step-free access and a seat near the door
g14 | Karen (Sam's manager) + partner | 2 seats | side Sam | group work | likely 60%
g16 | Zoe & Sami | 2 seats | side Sam | group uni
g18 | Cousin Anna + plus-one | 2 seats | side Sam | group family | tags plus-one | note Single at the moment
g19 | Cousin Raj & Nisha | 2 seats | side Alex | group family
g20 | Great-aunt Iris | 1 seat | side Sam | group family | likely 50%
g21 | Tasha & Kofi | 2 seats | side Sam | group climbing
g22 | Elliot | 1 seat | side Sam | group climbing
g28 | Femi & Bola | 2 seats | side Sam | group uni
g29 | Rosie + plus-one | 2 seats | side Sam | group school | tags plus-one | note Haven't seen her since 2019
g31 | Nora & Pete + baby | 3 seats | side Alex | group school | tags kids | likely 75%
g32 | Dan | 1 seat | side Sam | group uni | note Sam's ex, still close friends
g33 | Uncle Phil | 1 seat | side Sam | group family | likely 80%
g35 | Ingrid & Paul | 2 seats | side Alex | group rowing
g36 | Godmother Ruth | 1 seat | side Sam | group family
g39 | Liv & Sean (Sam's team) | 2 seats | side Sam | group work
g25 | Leo (Alex's team) | 1 seat | side Alex | group work
g27 | Jess + partner (Alex's team) | 2 seats | side Alex | group work
## Waitlist [b3] - 7 cards, 9 seats, not invited
g38 | Ken's carer | 1 seat | side Alex | group family | tags plus-one | likely 80% | note Only if Ken comes
g30 | Callum & Ade | 2 seats | side Alex | group uni
g23 | Dee & Hal (next door) | 2 seats | side Alex | group neighbours
g24 | Mrs Okafor (across the road) | 1 seat | side Alex | group neighbours | note Feeds the cat when we're away
g34 | Coach Martin | 1 seat | side Alex | group rowing
g40 | Aisha (Sam's team) | 1 seat | side Sam | group work
g26 | Mark (Alex's first manager) | 1 seat | side Alex | group work | note Gave Alex their first job
## Not this time [b4] - 0 cards, 0 seats, not invited
(empty)
## Unsorted pile [pile] - 0 cards, 0 seats
(empty)

settings, decoded:

{
 "currency": "£",
 "per_head": 95,
 "sides": [
  "Sam",
  "Alex"
 ],
 "invited_totals": {
  "cards": 33,
  "seats": 61,
  "expected": 49.7,
  "cost": 5795
 },
 "unsorted_cards": 0,
 "empty_tiers": [
  "b4"
 ]
}

tiers, decoded:

[
 {
  "id": "b1",
  "order": 1,
  "name": "A · Must invite",
  "invited": true,
  "rules": {
   "max_seats": 34,
   "require_tags": [
    "immediate"
   ]
  },
  "sums": {
   "cards": 15,
   "seats": 31,
   "expected": 25.5,
   "cost": 2945,
   "seats_with_tiers_above": 31,
   "cost_with_tiers_above": 2945
  }
 },
 {
  "id": "b2",
  "order": 2,
  "name": "B · If there's room",
  "invited": true,
  "rules": {
   "max_seats": 56,
   "budget": 6000,
   "cumulative": true
  },
  "sums": {
   "cards": 18,
   "seats": 30,
   "expected": 24.2,
   "cost": 2850,
   "seats_with_tiers_above": 61,
   "cost_with_tiers_above": 5795
  }
 },
 {
  "id": "b3",
  "order": 3,
  "name": "Waitlist",
  "invited": false,
  "rules": {
   "max_invites": 6
  },
  "sums": {
   "cards": 7,
   "seats": 9,
   "expected": 7.6,
   "cost": 855,
   "seats_with_tiers_above": 70,
   "cost_with_tiers_above": 6650
  }
 },
 {
  "id": "b4",
  "order": 4,
  "name": "Not this time",
  "invited": false,
  "rules": {},
  "sums": {
   "cards": 0,
   "seats": 0,
   "expected": 0,
   "cost": 0,
   "seats_with_tiers_above": 70,
   "cost_with_tiers_above": 6650
  }
 }
]

links, decoded:

[
 {
  "a": "g37",
  "b": "g38",
  "kind": "together"
 },
 {
  "a": "g28",
  "b": "g30",
  "kind": "together"
 },
 {
  "a": "g25",
  "b": "g27",
  "kind": "not_both"
 }
]

violations, decoded:

[
 {
  "key": "require|b1|immediate",
  "tier": "b1",
  "cards": [
   "g6"
  ],
  "text": "A · Must invite should hold every card tagged “immediate”; 1 is elsewhere"
 },
 {
  "key": "seats|b2",
  "tier": "b2",
  "cards": [],
  "text": "B · If there's room: 61 of 56 seats counting the tiers above (5 over)"
 },
 {
  "key": "invites|b3",
  "tier": "b3",
  "cards": [],
  "text": "Waitlist: 7 of 6 invitations (1 over)"
 },
 {
  "key": "together|g37|g38",
  "tier": "b1",
  "cards": [
   "g37",
   "g38"
  ],
  "text": "Grandpa Ken and Ken's carer belong together but sit in A · Must invite and Waitlist"
 },
 {
  "key": "together|g28|g30",
  "tier": "b2",
  "cards": [
   "g28",
   "g30"
  ],
  "text": "Femi & Bola and Callum & Ade belong together but sit in B · If there's room and Waitlist"
 },
 {
  "key": "notboth|g25|g27",
  "tier": "b2",
  "cards": [
   "g25",
   "g27"
  ],
  "text": "Invite one of Leo (Alex's team) or Jess + partner (Alex's team), not both - both are in invited tiers"
 }
]

coverage, decoded:

{
 "sides": [
  {
   "side": "Sam",
   "invited_seats": 32,
   "total_seats": 33,
   "share_pct": 52.5
  },
  {
   "side": "Alex",
   "invited_seats": 29,
   "total_seats": 37,
   "share_pct": 47.5
  }
 ],
 "uncovered_groups": [
  "neighbours"
 ],
 "imbalance": null
}

Reply (excerpt: the first 3 of 10 tensions and 3 of 7 moves; the page shows it whole):

{
 "task": "review",
 "headline": "The invited tiers hold 61 seats against the barn's 56 with no standing option, so 5 seats must come out before anything is sent - and Grandma Joan (g6) and Ken's carer (g38) still need to move up to the A list.",
 "tensions": [
  {
   "id": "t1",
   "kind": "rule",
   "tier": "b2",
   "cards": [],
   "severity": "high",
   "text": "B · If there's room reaches 61 of 56 seats counting the A list (5 over), and the brief says the barn seats 56 with no second sitting, so at least 5 seats must leave the invited tiers - more once Ken's carer is added."
  },
  {
   "id": "t2",
   "kind": "rule",
   "tier": "b1",
   "cards": [
    "g6"
   ],
   "severity": "high",
   "text": "A · Must invite should hold every card tagged immediate, but Grandma Joan (g6) sits in B; her note asks for step-free access and a seat near the door, which is logistics for the barn to confirm."
  },
  {
   "id": "t3",
   "kind": "split",
   "tier": "b1",
   "cards": [
    "g37",
    "g38"
   ],
   "severity": "high",
   "text": "Grandpa Ken (g37, 80% likely) is on the A list but Ken's carer (g38) is on the Waitlist; his note says he needs a carer with him, so the carer goes wherever he goes."
  }
 ],
 "moves": [
  {
   "id": "m1",
   "card": "g6",
   "to": "b1",
   "swap_with": null,
   "reason": "Immediate family belongs on the A list; 1 seat, b1 stays under its 34.",
   "fixes": [
    "t2"
   ]
  },
  {
   "id": "m2",
   "card": "g38",
   "to": "b1",
   "swap_with": null,
   "reason": "Carer sits with Grandpa Ken; adds 1 invited seat, so other cuts must cover it.",
   "fixes": [
    "t3",
    "t4"
   ]
  },
  {
   "id": "m3",
   "card": "g29",
   "to": "b4",
   "swap_with": null,
   "reason": "Frees 2 seats; plus-one breaks the settled-relationship rule and not seen since 2019.",
   "fixes": [
    "t1",
    "t8"
   ]
  }
 ],
 "questions": [
  "Do the cousins aged 6, 9 and 12 (g11) and Nora & Pete's baby (g31) count as immediate family's kids under your children rule?",
  "Could Cousin Anna (g18) come without the plus-one, freeing a seat?",
  "Can the barn give Grandma Joan (g6) step-free access and a seat by the door, and room for Ken's carer next to him?",
  "Would you rather invite all eight of Alex's team at a future do than none at the wedding?"
 ]
}

review: a 40th birthday dinner with 13 cards still in the pile

Input (no sides, no broken rules, two empty tiers):

{
 "task": "review",
 "event": "Maya's 40th - private room dinner",
 "context": "Private room at Osteria Lupa seats 24 at one long table; set menu $65 a head with wine. Anyone not at dinner can join for drinks at the bar next door from 10pm - the bar is fine with up to about 25 extra. Maya wants a mix of her worlds at the table, not just the uni crowd. Partners only if Maya knows them.",
 "settings": "(a JSON string - decoded below)",
 "tiers": "(a JSON string - decoded below)",
 "cards": "...",
 "links": "(a JSON string - decoded below)",
 "violations": "(a JSON string - decoded below)",
 "coverage": "(a JSON string - decoded below)"
}

cards, as the text the model reads:

## Dinner [b1] - 9 cards, 15 seats, invited
g1 | Jo & Sam (sister) | 2 seats | group family | tags family
g2 | Mum | 1 seat | group family | tags family
g3 | Lucy & Ed | 2 seats | group uni
g4 | Ana & Pedro | 2 seats | group uni
g5 | Rich | 1 seat | group uni
g6 | Tom & Kat | 2 seats | group uni
g7 | Becca + new boyfriend | 2 seats | group uni | tags plus-one | note Maya hasn't met him
g20 | Theo | 1 seat | group uni | note Lucy's brother; came to the 30th
g14 | Mei & Lars | 2 seats | group work
## Drinks after [b2] - 0 cards, 0 seats, invited
(empty)
## Not this time [b3] - 0 cards, 0 seats, not invited
(empty)
## Unsorted pile [pile] - 13 cards, 18 seats
g8 | Priti & Jon | 2 seats | group school
g9 | Claire | 1 seat | group school
g10 | Nadia & Sol | 2 seats | group climbing
g11 | Gus | 1 seat | group climbing
g12 | Fen | 1 seat | group climbing
g13 | Hugo (boss) | 1 seat | group work | likely 60%
g15 | Ola | 1 seat | group work
g16 | Ines | 1 seat | group book-club
g17 | Ruth & Dave | 2 seats | group book-club
g18 | Cousin Kit | 1 seat | group family | likely 50% | note Lives three hours away
g19 | Dom & Aya | 2 seats | group neighbours
g21 | Sasha | 1 seat | group work | note Left the company last year, still close
g22 | Mo & Lin | 2 seats | group climbing

settings, decoded:

{
 "currency": "$",
 "per_head": 65,
 "sides": [],
 "invited_totals": {
  "cards": 9,
  "seats": 15,
  "expected": 12.7,
  "cost": 975
 },
 "unsorted_cards": 13,
 "empty_tiers": [
  "b2",
  "b3"
 ]
}

tiers, decoded:

[
 {
  "id": "b1",
  "order": 1,
  "name": "Dinner",
  "invited": true,
  "rules": {
   "max_seats": 24,
   "budget": 1700
  },
  "sums": {
   "cards": 9,
   "seats": 15,
   "expected": 12.7,
   "cost": 975,
   "seats_with_tiers_above": 15,
   "cost_with_tiers_above": 975
  }
 },
 {
  "id": "b2",
  "order": 2,
  "name": "Drinks after",
  "invited": true,
  "rules": {
   "max_invites": 25
  },
  "sums": {
   "cards": 0,
   "seats": 0,
   "expected": 0,
   "cost": 0,
   "seats_with_tiers_above": 15,
   "cost_with_tiers_above": 975
  }
 },
 {
  "id": "b3",
  "order": 3,
  "name": "Not this time",
  "invited": false,
  "rules": {},
  "sums": {
   "cards": 0,
   "seats": 0,
   "expected": 0,
   "cost": 0,
   "seats_with_tiers_above": 15,
   "cost_with_tiers_above": 975
  }
 }
]

links, decoded:

[
 {
  "a": "g3",
  "b": "g20",
  "kind": "together"
 }
]

violations, decoded:

[]

coverage, decoded:

{
 "sides": [
  {
   "side": "unassigned",
   "invited_seats": 15,
   "total_seats": 33,
   "share_pct": 100
  }
 ],
 "uncovered_groups": [
  "book-club",
  "climbing",
  "neighbours",
  "school"
 ],
 "imbalance": null
}

Reply (excerpt: the first 3 of 5 tensions and 3 of 10 moves; the page shows it whole):

{
 "task": "review",
 "headline": "Dinner holds 15 of 24 seats, almost all from the uni crowd, with 13 cards still in the pile and Drinks after empty - fill the table with Maya's other worlds and send the rest to drinks.",
 "tensions": [
  {
   "id": "t1",
   "kind": "empty",
   "tier": "pile",
   "cards": [
    "g8",
    "g9",
    "g10",
    "g11",
    "g12",
    "g13",
    "g15",
    "g16",
    "g17",
    "g18",
    "g19",
    "g21",
    "g22"
   ],
   "severity": "high",
   "text": "13 cards (18 seats) are still unsorted; each needs a place at dinner, at drinks from 10pm, or not this time."
  },
  {
   "id": "t2",
   "kind": "balance",
   "tier": "b1",
   "cards": [
    "g3",
    "g4",
    "g5",
    "g6",
    "g7",
    "g20"
   ],
   "severity": "high",
   "text": "Maya wants a mix of her worlds, but six of the nine Dinner cards are uni and the school, climbing, book-club and neighbours groups have nobody invited."
  },
  {
   "id": "t3",
   "kind": "empty",
   "tier": "b2",
   "cards": [],
   "severity": "medium",
   "text": "Drinks after is empty although the bar takes up to about 25 extra; it is the natural home for everyone who doesn't fit the 24-seat table."
  }
 ],
 "moves": [
  {
   "id": "m1",
   "card": "g8",
   "to": "b1",
   "swap_with": null,
   "reason": "Brings the school friends to the table.",
   "fixes": [
    "t1",
    "t2"
   ]
  },
  {
   "id": "m2",
   "card": "g9",
   "to": "b1",
   "swap_with": null,
   "reason": "Sits with Priti & Jon so school isn't one couple alone.",
   "fixes": [
    "t1",
    "t2"
   ]
  },
  {
   "id": "m3",
   "card": "g10",
   "to": "b1",
   "swap_with": null,
   "reason": "Adds the climbing crowd to dinner.",
   "fixes": [
    "t1",
    "t2"
   ]
  }
 ],
 "questions": [
  "Has Maya met Becca's new boyfriend, or should Becca's card be one seat?",
  "Should Hugo, as Maya's boss, be at dinner or at drinks?",
  "Is Cousin Kit (three hours away) likely to make a 10pm drinks, or is dinner the only realistic invite?",
  "Does Sasha belong with the work crowd at dinner or at drinks?"
 ]
}

review: a child's trampoline party, a clean board

Input (an exclude_tags rule, nothing broken):

{
 "task": "review",
 "event": "Leo turns 7 - trampoline park",
 "context": "Trampoline park party package: up to 12 jumpers at $22 each, grip socks $3 a child on top. Children must be 5 or older to jump. Leo's class has 14 kids; school asks that invitations either go to the whole class or are given out privately. Parents stay but don't count.",
 "settings": "(a JSON string - decoded below)",
 "tiers": "(a JSON string - decoded below)",
 "cards": "...",
 "links": "(a JSON string - decoded below)",
 "violations": "(a JSON string - decoded below)",
 "coverage": "(a JSON string - decoded below)"
}

cards, as the text the model reads:

## Jumping [b1] - 12 cards, 12 seats, invited
g2 | Noah | 1 seat | group class | extra 3 | likely 90% | note Leo's best friend
g1 | Ava | 1 seat | group class | extra 3 | likely 90%
g3 | Isla | 1 seat | group class | extra 3
g4 | Kai | 1 seat | group class | extra 3 | note Nut allergy
g5 | Ruby | 1 seat | group class | extra 3
g12 | Theo | 1 seat | group class | extra 3 | likely 80%
g13 | Lily | 1 seat | group class | extra 3 | likely 80%
g6 | Omar | 1 seat | group football | extra 3 | likely 90%
g7 | Finn | 1 seat | group football | extra 3 | likely 80%
g8 | Cousin Maisie | 1 seat | group cousins | extra 3 | likely 95%
g9 | Cousin Alfie | 1 seat | group cousins | extra 3 | likely 95%
g11 | Zara (next door) | 1 seat | group neighbours | extra 3 | likely 90%
## Waitlist [b2] - 3 cards, 3 seats, not invited
g14 | Jack | 1 seat | group football | extra 3 | likely 70%
g15 | Mila | 1 seat | group class | extra 3 | likely 75%
g16 | Arlo | 1 seat | group class | extra 3 | likely 75%
## Not this time [b3] - 1 card, 1 seat, not invited
g10 | Cousin Bea | 1 seat | group cousins | tags under-5 | likely 95% | note Turns 4 next month
## Unsorted pile [pile] - 0 cards, 0 seats
(empty)

settings, decoded:

{
 "currency": "$",
 "per_head": 22,
 "sides": [],
 "invited_totals": {
  "cards": 12,
  "seats": 12,
  "expected": 10.5,
  "cost": 300
 },
 "unsorted_cards": 0,
 "empty_tiers": []
}

tiers, decoded:

[
 {
  "id": "b1",
  "order": 1,
  "name": "Jumping",
  "invited": true,
  "rules": {
   "max_seats": 12,
   "budget": 300,
   "exclude_tags": [
    "under-5"
   ]
  },
  "sums": {
   "cards": 12,
   "seats": 12,
   "expected": 10.5,
   "cost": 300,
   "seats_with_tiers_above": 12,
   "cost_with_tiers_above": 300
  }
 },
 {
  "id": "b2",
  "order": 2,
  "name": "Waitlist",
  "invited": false,
  "rules": {
   "max_invites": 6
  },
  "sums": {
   "cards": 3,
   "seats": 3,
   "expected": 2.2,
   "cost": 75,
   "seats_with_tiers_above": 15,
   "cost_with_tiers_above": 375
  }
 },
 {
  "id": "b3",
  "order": 3,
  "name": "Not this time",
  "invited": false,
  "rules": {},
  "sums": {
   "cards": 1,
   "seats": 1,
   "expected": 1,
   "cost": 22,
   "seats_with_tiers_above": 16,
   "cost_with_tiers_above": 397
  }
 }
]

links, decoded:

[
 {
  "a": "g8",
  "b": "g9",
  "kind": "together"
 }
]

violations, decoded:

[]

coverage, decoded:

{
 "sides": [
  {
   "side": "unassigned",
   "invited_seats": 12,
   "total_seats": 16,
   "share_pct": 100
  }
 ],
 "uncovered_groups": [],
 "imbalance": null
}

Reply (excerpt: the first 3 of 4 tensions; the page shows it whole):

{
 "task": "review",
 "headline": "The sort satisfies every rule - 12 of 12 jumpers and $300 of a $300 budget - but invitations to part of Leo's class must be given out privately, and the budget has no contingency.",
 "tensions": [
  {
   "id": "t1",
   "kind": "other",
   "tier": "b1",
   "cards": [
    "g1",
    "g2",
    "g3",
    "g4",
    "g5",
    "g12",
    "g13"
   ],
   "severity": "medium",
   "text": "Seven class cards are Jumping and Mila (g15) and Arlo (g16) are on the Waitlist, out of a class of 14; the school asks for whole-class invitations or private ones, so these must be handed out privately."
  },
  {
   "id": "t2",
   "kind": "other",
   "tier": "b1",
   "cards": [],
   "severity": "medium",
   "text": "Jumping lands at $300 of a $300 budget and 12 of 12 seats, leaving no contingency for a sibling who turns up or an extra party bag."
  },
  {
   "id": "t3",
   "kind": "other",
   "tier": "b2",
   "cards": [
    "g14",
    "g15",
    "g16"
   ],
   "severity": "low",
   "text": "With 10.5 expected of 12 jumpers, the Waitlist of Jack (g14), Mila (g15) and Arlo (g16) is about the right length; send it quickly as declines come in so no child feels like a substitute."
  }
 ],
 "moves": [],
 "questions": [
  "Can the park add a 13th jumper if a waitlisted child accepts late?",
  "Will Cousin Bea (g10) come along to watch with a parent, given she can't jump until 5?"
 ]
}

recheck: the same wedding after the hosts moved five cards

Input (with previous from Reader.compact and changes from Board.diff):

{
 "task": "recheck",
 "event": "Sam & Alex - barn wedding",
 "context": "Sam & Alex, a barn wedding in June. The barn seats 56 for the dinner and there is no standing or second-sitting option. £95 a head covers food and drink. We'd rather keep it intimate than squeeze. Adults-only, except the immediate family's own kids. Plus-ones only for people in a settled relationship. Alex's team is eight people - inviting one or two without the rest could be awkward.",
 "settings": "(a JSON string - decoded below)",
 "tiers": "(a JSON string - decoded below)",
 "cards": "...",
 "links": "(a JSON string - decoded below)",
 "violations": "(a JSON string - decoded below)",
 "coverage": "(a JSON string - decoded below)",
 "previous": "(a JSON string - decoded below)",
 "changes": "(a JSON string - decoded below)"
}

cards, as the text the model reads:

## A · Must invite [b1] - 17 cards, 33 seats, invited
g1 | Mum & Dad (Sam's) | 2 seats | side Sam | group family | tags immediate
g2 | Mum & Steve (Alex's) | 2 seats | side Alex | group family | tags immediate
g3 | Dad (Alex's) | 1 seat | side Alex | group family | tags immediate | note Doesn't drive after dark
g4 | Ellie & Jon + Mia and Leo | 4 seats | side Sam | group family | tags immediate,kids
g5 | Ben & Ruth | 2 seats | side Alex | group family | tags immediate
g7 | Nana Pat | 1 seat | side Alex | group family | tags immediate
g8 | Priya + Tom (maid of honour) | 2 seats | side Sam | group uni | tags wedding-party | likely 95%
g9 | Marcus + Leah (best man) | 2 seats | side Alex | group school | tags wedding-party | likely 95%
g10 | Aunt Carol & Uncle Rob | 2 seats | side Sam | group family
g11 | Uncle Dev & Aunt Meera + 3 cousins | 5 seats | side Alex | group family | tags kids | likely 80% | note Cousins are 6, 9 and 12
g13 | Jamie | 1 seat | side Alex | group school
g12 | Hannah & Chris | 2 seats | side Sam | group uni
g15 | Oliver & Grace | 2 seats | side Alex | group uni
g17 | Will & Freya (Sydney) | 2 seats | side Alex | group school | tags travelling | likely 35% | note Would fly in; said they'd try
g37 | Grandpa Ken | 1 seat | side Alex | group family | tags immediate | likely 80% | note Lives in a care home; needs a carer with him
g6 | Grandma Joan | 1 seat | side Sam | group family | tags immediate | likely 70% | note Needs step-free access and a seat near the door
g38 | Ken's carer | 1 seat | side Alex | group family | tags plus-one | likely 80% | note Only if Ken comes
## B · If there's room [b2] - 14 cards, 22 seats, invited
g14 | Karen (Sam's manager) + partner | 2 seats | side Sam | group work | likely 60%
g16 | Zoe & Sami | 2 seats | side Sam | group uni
g18 | Cousin Anna + plus-one | 2 seats | side Sam | group family | tags plus-one | note Single at the moment
g19 | Cousin Raj & Nisha | 2 seats | side Alex | group family
g20 | Great-aunt Iris | 1 seat | side Sam | group family | likely 50%
g21 | Tasha & Kofi | 2 seats | side Sam | group climbing
g22 | Elliot | 1 seat | side Sam | group climbing
g28 | Femi & Bola | 2 seats | side Sam | group uni
g32 | Dan | 1 seat | side Sam | group uni | note Sam's ex, still close friends
g33 | Uncle Phil | 1 seat | side Sam | group family | likely 80%
g35 | Ingrid & Paul | 2 seats | side Alex | group rowing
g36 | Godmother Ruth | 1 seat | side Sam | group family
g39 | Liv & Sean (Sam's team) | 2 seats | side Sam | group work
g25 | Leo (Alex's team) | 1 seat | side Alex | group work
## Waitlist [b3] - 7 cards, 11 seats, not invited
g30 | Callum & Ade | 2 seats | side Alex | group uni
g23 | Dee & Hal (next door) | 2 seats | side Alex | group neighbours
g24 | Mrs Okafor (across the road) | 1 seat | side Alex | group neighbours | note Feeds the cat when we're away
g34 | Coach Martin | 1 seat | side Alex | group rowing
g40 | Aisha (Sam's team) | 1 seat | side Sam | group work
g26 | Mark (Alex's first manager) | 1 seat | side Alex | group work | note Gave Alex their first job
g31 | Nora & Pete + baby | 3 seats | side Alex | group school | tags kids | likely 75%
## Not this time [b4] - 2 cards, 4 seats, not invited
g29 | Rosie + plus-one | 2 seats | side Sam | group school | tags plus-one | note Haven't seen her since 2019
g27 | Jess + partner (Alex's team) | 2 seats | side Alex | group work
## Unsorted pile [pile] - 0 cards, 0 seats
(empty)

settings, decoded:

{
 "currency": "£",
 "per_head": 95,
 "sides": [
  "Sam",
  "Alex"
 ],
 "invited_totals": {
  "cards": 31,
  "seats": 55,
  "expected": 44.8,
  "cost": 5225
 },
 "unsorted_cards": 0,
 "empty_tiers": []
}

tiers, decoded:

[
 {
  "id": "b1",
  "order": 1,
  "name": "A · Must invite",
  "invited": true,
  "rules": {
   "max_seats": 34,
   "require_tags": [
    "immediate"
   ]
  },
  "sums": {
   "cards": 17,
   "seats": 33,
   "expected": 27,
   "cost": 3135,
   "seats_with_tiers_above": 33,
   "cost_with_tiers_above": 3135
  }
 },
 {
  "id": "b2",
  "order": 2,
  "name": "B · If there's room",
  "invited": true,
  "rules": {
   "max_seats": 56,
   "budget": 6000,
   "cumulative": true
  },
  "sums": {
   "cards": 14,
   "seats": 22,
   "expected": 17.8,
   "cost": 2090,
   "seats_with_tiers_above": 55,
   "cost_with_tiers_above": 5225
  }
 },
 {
  "id": "b3",
  "order": 3,
  "name": "Waitlist",
  "invited": false,
  "rules": {
   "max_invites": 6
  },
  "sums": {
   "cards": 7,
   "seats": 11,
   "expected": 9,
   "cost": 1045,
   "seats_with_tiers_above": 66,
   "cost_with_tiers_above": 6270
  }
 },
 {
  "id": "b4",
  "order": 4,
  "name": "Not this time",
  "invited": false,
  "rules": {},
  "sums": {
   "cards": 2,
   "seats": 4,
   "expected": 3.4,
   "cost": 380,
   "seats_with_tiers_above": 70,
   "cost_with_tiers_above": 6650
  }
 }
]

links, decoded:

[
 {
  "a": "g37",
  "b": "g38",
  "kind": "together"
 },
 {
  "a": "g28",
  "b": "g30",
  "kind": "together"
 },
 {
  "a": "g25",
  "b": "g27",
  "kind": "not_both"
 }
]

violations, decoded:

[
 {
  "key": "invites|b3",
  "tier": "b3",
  "cards": [],
  "text": "Waitlist: 7 of 6 invitations (1 over)"
 },
 {
  "key": "together|g28|g30",
  "tier": "b2",
  "cards": [
   "g28",
   "g30"
  ],
  "text": "Femi & Bola and Callum & Ade belong together but sit in B · If there's room and Waitlist"
 }
]

coverage, decoded:

{
 "sides": [
  {
   "side": "Sam",
   "invited_seats": 30,
   "total_seats": 33,
   "share_pct": 54.5
  },
  {
   "side": "Alex",
   "invited_seats": 25,
   "total_seats": 37,
   "share_pct": 45.5
  }
 ],
 "uncovered_groups": [
  "neighbours"
 ],
 "imbalance": null
}

previous, decoded:

{
 "tensions": [
  {
   "id": "t1",
   "kind": "rule",
   "tier": "b2",
   "cards": [],
   "text": "B · If there's room reaches 61 of 56 seats counting the A list (5 over), and the brief says the barn seats 56 with no second sitting, so at least 5 seats must l"
  },
  {
   "id": "t2",
   "kind": "rule",
   "tier": "b1",
   "cards": [
    "g6"
   ],
   "text": "A · Must invite should hold every card tagged immediate, but Grandma Joan (g6) sits in B; her note asks for step-free access and a seat near the door, which is "
  },
  {
   "id": "t3",
   "kind": "split",
   "tier": "b1",
   "cards": [
    "g37",
    "g38"
   ],
   "text": "Grandpa Ken (g37, 80% likely) is on the A list but Ken's carer (g38) is on the Waitlist; his note says he needs a carer with him, so the carer goes wherever he "
  },
  {
   "id": "t4",
   "kind": "rule",
   "tier": "b3",
   "cards": [],
   "text": "The Waitlist holds 7 of 6 invitations (1 over); with the venue already 5 seats over, a long waitlist is also more hope than the declines are likely to free."
  },
  {
   "id": "t5",
   "kind": "split",
   "tier": "b2",
   "cards": [
    "g28",
    "g30"
   ],
   "text": "Femi & Bola (g28) and Callum & Ade (g30) are linked together but sit in B and the Waitlist; keep the pair in one tier, either both invited or both held back."
  },
  {
   "id": "t6",
   "kind": "split",
   "tier": "b2",
   "cards": [
    "g25",
    "g27"
   ],
   "text": "Leo (g25) and Jess + partner (g27) are marked invite-one-not-both yet both are invited, and the brief warns that inviting one or two of Alex's eight-person team"
  },
  {
   "id": "t7",
   "kind": "misfit",
   "tier": "b1",
   "cards": [
    "g11",
    "g31"
   ],
   "text": "The brief is adults-only except the immediate family's own kids, but Uncle Dev & Aunt Meera + 3 cousins (g11, 5 seats) and Nora & Pete + baby (g31, 3 seats) car"
  },
  {
   "id": "t8",
   "kind": "misfit",
   "tier": "b2",
   "cards": [
    "g18",
    "g29"
   ],
   "text": "Plus-ones are only for settled relationships, yet Cousin Anna (g18, note: single at the moment) and Rosie (g29) each hold a plus-one seat; Rosie's note also say"
  },
  {
   "id": "t9",
   "kind": "misfit",
   "tier": "b1",
   "cards": [
    "g17"
   ],
   "text": "Will & Freya (g17) hold 2 A-list seats at 35% likely, flying in from Sydney, while the cap is 5 over; a seat is still owed if invited, but an early answer from "
  },
  {
   "id": "t10",
   "kind": "balance",
   "tier": "b3",
   "cards": [
    "g23",
    "g24"
   ],
   "text": "The neighbours group has nobody invited; Dee & Hal (g23) and Mrs Okafor (g24) both sit on the Waitlist, which is fine if that is the intent."
  }
 ],
 "moves": [
  {
   "id": "m1",
   "card": "g6",
   "to": "b1",
   "swap_with": null,
   "status": "accepted"
  },
  {
   "id": "m2",
   "card": "g38",
   "to": "b1",
   "swap_with": null,
   "status": "accepted"
  },
  {
   "id": "m3",
   "card": "g29",
   "to": "b4",
   "swap_with": null,
   "status": "accepted"
  
... (shortened here)

changes, decoded:

{
 "moves": [
  {
   "card": "g6",
   "from": "b2",
   "to": "b1"
  },
  {
   "card": "g27",
   "from": "b2",
   "to": "b4"
  },
  {
   "card": "g29",
   "from": "b2",
   "to": "b4"
  },
  {
   "card": "g31",
   "from": "b2",
   "to": "b3"
  },
  {
   "card": "g38",
   "from": "b3",
   "to": "b1"
  }
 ],
 "added": [],
 "removed": [],
 "edited": [],
 "rules": [],
 "links": false,
 "settings": false,
 "any": true
}

Reply (excerpt: the first 3 of 7 tensions; the page shows it whole):

{
 "task": "recheck",
 "headline": "The invited tiers are now 55 of 56 seats and Grandma Joan (g6) and Ken's carer (g38) are on the A list, but moving Nora & Pete + baby (g31) to the Waitlist put it back at 7 of 6 invitations, and Femi & Bola (g28) are still apart from Callum & Ade (g30).",
 "tensions": [
  {
   "id": "t11",
   "kind": "rule",
   "tier": "b3",
   "cards": [
    "g31"
   ],
   "severity": "high",
   "text": "The Waitlist holds 7 of 6 invitations (1 over): sending Nora & Pete + baby (g31) there kept the adults-only rule but refilled the slot the carer's move had freed."
  },
  {
   "id": "t12",
   "kind": "split",
   "tier": "b2",
   "cards": [
    "g28",
    "g30"
   ],
   "severity": "medium",
   "text": "Femi & Bola (g28) in B and Callum & Ade (g30) on the Waitlist are linked together but still sit apart; with 55 of 56 seats used there is no room to bring the pair in together."
  },
  {
   "id": "t13",
   "kind": "misfit",
   "tier": "b2",
   "cards": [
    "g25"
   ],
   "severity": "medium",
   "text": "With Jess + partner (g27) moved to Not this time, Leo (g25) is the only one of Alex's eight-person team invited, the partial invite the brief calls awkward."
  }
 ],
 "moves": [
  {
   "id": "m1",
   "card": "g30",
   "to": "b4",
   "swap_with": null,
   "reason": "Brings the Waitlist back to its limit of 6 invitations.",
   "fixes": [
    "t11",
    "t12"
   ]
  },
  {
   "id": "m2",
   "card": "g28",
   "to": "b4",
   "swap_with": null,
   "reason": "Rejoins Callum & Ade; no room to invite both couples under 56.",
   "fixes": [
    "t12"
   ]
  },
  {
   "id": "m3",
   "card": "g25",
   "to": "b4",
   "swap_with": null,
   "reason": "Avoids inviting one person from Alex's team of eight; frees 1 seat.",
   "fixes": [
    "t13",
    "t16"
   ]
  }
 ],
 "questions": [
  "Do the cousins aged 6, 9 and 12 (g11) count as immediate family's kids under your children rule?",
  "Could Cousin Anna (g18) come without the plus-one, freeing a seat?",
  "Can the barn give Grandma Joan (g6) step-free access and a seat by the door now she is on the A list?"
 ],
 "recheck": {
  "resolved": [
   "t1",
   "t2",
   "t3",
   "t6"
  ],
  "still_open": [
   "t4",
   "t5",
   "t7",
   "t8",
   "t9",
   "t10"
  ],
  "note": "Your moves fixed the seat cap, Grandma Joan and Ken's carer, and the Leo-or-Jess pair; moving g31 to the Waitlist fixed the kids issue for that card but re-broke the Waitlist limit."
 }
}

The page does the arithmetic and checks the reply; the API does not

Over the API you get the review as JSON. The sums, violations and coverage in the input are yours to compute, and the page's own code is plain scripts you can load in node after global.window = {} (load board.js first, then reader.js, which reads window.Board):

// node: global.window = {}; then require("./board.js"); require("./reader.js");
const B = window.Board, R = window.Reader;
const comp = B.compute(board);                                 // board: {buckets, cards, place, links, ...}
const { input } = B.buildInput(comp, { task: "review" });      // the run body
// ... run it, then:
const res = R.normalize(R.parseResult(job.output.output));
const recon = R.reconcile(res, comp.board);                    // recon.moves, recon.notes, recon.missed
const next = B.buildInput(B.compute(after), { task: "recheck",
  previous: R.compact(recon, { m1: "accepted", m2: "dismissed" }), changes: B.diff(board, after) }).input;

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope. Your personal token comes from /tokens.html.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://invite-tiers.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":"invite-tiers"}' | 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": ...}), with the JSON-valued fields as strings. hold_credits is a reservation, not the price; min_credits is what the balance must cover; warnings names unknown or missing fields.

# input.json: the run input object - {"task": "review", "event": "...", "context": "...", "settings": "{...}", "tiers": "[...]", "cards": "...", "links": "[...]", "violations": "[...]", "coverage": "{...}"}
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, the input and the attempt: a retry with the same key never bills twice. The page uses invite-tiers:<task>:<inputHash>:a<attempt> (its own short hash; any stable hash of the body works) and bumps the attempt only for the one deliberate retry that adds retry_note. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="invite-tiers:review:$(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: invite-tiers:review:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json