← Fair Hour
API

Drive Fair Hour from your own code

The one metered task, slots, is available over HTTP. It takes a meeting search as the page works it out - what the meeting is for and how long it is, the dates searched, every person as one numbered line (p1, p2 ...) with their time zone, UTC offset, hours, busy times and whether they are optional, every candidate slot as one numbered line (s1, s2 ...) with its start in UTC and each person's local day and time, the counts and the organizer's preferences - and returns the best one to three slots (each with a reason, and a question for whoever would have to move when the slot misses someone required), a verdict (found or near), a drafted invite (subject and body) and a note.

The division of labour first: the page reads everyone's time zone, hours and busy times, converts them with the time-zone data for the chosen dates (daylight saving included) and finds the candidate slots with every person's local time; the model picks the fairest, says why and drafts the invite, referring to slots only by id so the page can check every one. The page reads up to 12 people, searches on a 15-minute grid, scores each start by the minutes that fall in someone's first or last hour (60 minutes), and sends up to 8 candidates. After the reply it keeps up to three picks of slots it sent, takes every time from its own search and never from the reply, checks the verdict against the first pick, and builds the invite file, the CSV and the lanes. Your code can do the same with the engine files the page loads (see "Build the input yourself").

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

The token is minted for this app (the guest call takes {"slug":"fair-hour"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, meeting, dates, people, slots, counts, prefs) is missing.
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: the slots run is metered and needs a personal sign-in.
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, the input and the rules

Every run sends task: slots (the one task is slots); a missing or different one is still answered as slots. All fields are strings.

FieldRequiredWhat it holds
taskyesAlways slots.
meetingyesWhat the meeting is for and how long it is: Launch readiness sync - 45 minutes.
datesyesThe dates searched, in the organizer's time zone: Tue 3 Nov (the organizer's dates, Europe/London).
peopleyesOne line per person, at most 12, the first being the organizer: p1. Priya (organizer) - Europe/London (UTC+0) - hours 09:00-17:30 Mon-Fri - busy 10:00-11:00, 14:00-15:30 - id, name, time zone with its UTC offset on the first date, hours, working days, busy times in their own clock, and optional when marked.
slotsyesOne line per candidate, at most 8, in time order: s1. Tue 3 Nov 09:15 UTC | Priya Tue 09:15-10:00 (45 min in their first or last hour); Marco Tue 04:15-05:00 (not free: 4 h 45 min before their hours start); Aiko Tue 18:15-19:00 (45 min in their first or last hour); Dev Tue 14:45-15:30 | missing required: Marco - id, start in UTC, then each person's local day and time with the minutes in their first or last hour, or not free: and why (busy, a day off, or how far before their hours start or after they end), then who is missing.
countsyesHow many people, how many required, and how many start times work for everyone required: 4 people, 3 required; no start time works for everyone required; 3 closest slots sent, each missing one person.
prefsyesThe organizer's preferences in their own words (at most 300 characters): Aiko usually joins our calls late in her evening - spare her that if we can, or none.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

How the page reads the people (SlotKit): one person per line, up to 12, the first being the organizer, in the form name | city or time zone | hours | busy times | optional | every day. The time zone is a city it knows (98 names, such as London, New York, Bangalore or Sao Paulo), an IANA zone in any case (Asia/Tokyo) or a whole-hour offset (UTC+3, GMT-5); a line with no zone it knows is not read, and the page says so. Hours: 9-17:30, 9am-5pm, 13:00-14:00, possibly past midnight; with none, 09:00-17:00 is used. Busy times are in the person's own clock: busy 10-11, 14:00-15:30, a weekday before a range for that day only (busy Tue 11-12), and a busy range with no am or pm that starts before 7 is read as the afternoon (1-2 is 13:00-14:00). optional (or opt, if possible, nice to have) marks someone the meeting can go ahead without; every day (or any day, 7 days, weekends too) makes their weekend count as working days. Parts the page cannot read are ignored, with a warning.

The search (SlotKit.search) needs no run: the dates are the organizer's chosen date and the next weekdays after it (1, 3 or 5 of them; weekends too when the organizer works every day), each searched from the organizer's local midnight to the next on a 15-minute grid. A start works for a person when the whole meeting falls inside their hours, on one of their working days in their own clock, clear of their busy times. Starts that miss more than one required person are dropped. Each start is scored by the minutes of the meeting that fall in each free person's first or last hour of the day; starts where everyone required is free come first; among starts that miss someone, the smallest move for the person missing (being busy counts as an hour, a day off as a whole day); then fewer optional people not free, then the smallest such share for any one person, then the smallest total, then the earlier start. Up to 8 of the best become the candidates, each at least an hour from the others, numbered in time order. When no start works for everyone required, the closest starts - each missing one required person - are sent instead. The page needs at least two people, one of them required, and a date.

The model's rules: one to three slot ids, best first - a slot where every required person is free beats one that misses a required person, and between slots that miss someone, one where that person is busy beats one outside their hours, and a shorter time outside their hours beats a longer one; then the preferences where a slot allows them; then fewer optional people not free; then the smallest largest share of first-or-last-hour minutes for any one person, so no one carries the awkward hour alone; then the earlier slot. Each pick has a reason of at most 25 words naming who it suits and who it stretches, using their local times from the slot line, and, only for a slot that misses a required person, a question of at most 25 words to that person: when they are busy or at most 2 hours outside their hours, whether they could join at their local time from the slot line; when they are further outside their hours, never to join then, but whether they could send an update or someone in their place. The verdict is found when the first pick has every required person free, otherwise near. The invite has a subject of at most 10 words from the meeting and a body of at most 70 words giving the first pick's time for each person who is free in it, in their own local time exactly as the slot line gives it, and nothing about who is optional, stretched or missing. The note says in at most 30 words how many slots worked for everyone and why the first pick wins, or, for near, who would need to move. Sentences name people by name and slots by their time, never by id; every time is copied from a slot or people line, never converted by the model; nothing is invented - no person, time zone, agenda, link, room or reason.

The reply

One JSON object:

{"task": "slots", "verdict": "found", "picks": [{"slot": "s1", "why": "...", "ask": ""}], "invite": {"subject": "...", "body": "..."}, "note": "..."}
FieldWhat it holds
verdictfound (the first pick has every required person free) or near (someone required would have to move).
picks[].slotThe id of a slot the page sent (s1 ...); one to three picks, best first.
picks[].whyOne sentence: who the slot suits and who it stretches, in their local times.
picks[].askFor a slot that misses a required person: one sentence to them - whether they could join at that time, or, when it is more than 2 hours outside their hours, whether they could send an update or someone in their place. Otherwise "".
invite.subjectThe invite's subject, at most 10 words, from the meeting.
invite.bodyThe invite's body, at most 70 words: what the meeting is for and the first pick's time for each person free in it, in their own local time.
noteOne sentence: how many slots worked for everyone and why the first pick wins, or, for near, who would need to move.

The page reads the reply with ReplyKit.normalize (the verdict and slot ids matched in any case) and keeps what it trusts with ReplyKit.trusted(reply, built): a pick is set aside, with the reason, when it names a slot the page did not send, when its slot is already picked, or when three picks are already kept. Every time the page shows, lists and puts in the invite file comes from its own search, never from the reply's sentences. The first kept pick is what the page checks the verdict against.

ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one that the first pick contradicts; every pick set aside; no usable pick at all; a pick that misses someone required while another slot has everyone required free; a pick that misses someone but has no question, or has everyone free and still asks one; a pick with no reason; an invite that gives the time of someone who is not free in the first pick; a question asking someone to join more than 2 hours outside their hours; when there are no preferences, a first pick that is not the fairest by the page's own count; an invite subject over 10 words, a body over 70 words or no invite; any number in a sentence or the invite that is not in the page's own times; a sentence that names an id, an internal field or uses backticks; and words such as "again", "already", "always", "never" or "every" that the meeting, the people and the preferences never used. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "A time works for everyone" or "Someone would have to move".

The downloads: ReplyKit.inviteText(built, reply, t) copies the invite - the subject, the body and every person's local time for the first pick; SlotKit.toICS(start, minutes, {uid, subject, body}) writes it as an .ics file in UTC (lines end in CRLF, long lines folded); ReplyKit.csv(built) writes one row per candidate slot (slot, UTC start, everyone required free, missing, then one column per person with their local time) with CRLF line ends; ReplyKit.md and ReplyKit.json write the picks with every person's time, and the side-by-side lanes save as PNG or SVG.

Worked examples - every sample

These are the free samples on the page: the people as typed, the people as the page reads them, the candidate slots with every person's local time, the exact input it sends, the reply that replays for free, and what the page does with it - the picks it trusts, the verdict against the first pick and its own check. Together they cover the verdicts near and found.

London, New York and Tokyo

The form: Launch readiness sync, 45 minutes, searched on Tue 3 Nov; preferences: Aiko usually joins our calls late in her evening - spare her that if we can. The people as typed:

Priya | London | 9-17:30 | busy 10-11, 14:00-15:30
Marco | New York | 9am-5pm | busy 9:30-10
Aiko | Tokyo | 8-19
Dev | Bangalore | 10-19 | optional

The people as the page reads them, each with their time zone and its UTC offset at noon on the first date (Priya - Europe/London, UTC+0; Marco - America/New_York, UTC-5; Aiko - Asia/Tokyo, UTC+9; Dev - Asia/Kolkata, UTC+5:30, optional), and the dates searched (SlotKit.buildInput):

p1. Priya (organizer) - Europe/London (UTC+0) - hours 09:00-17:30 Mon-Fri - busy 10:00-11:00, 14:00-15:30
p2. Marco - America/New_York (UTC-5) - hours 09:00-17:00 Mon-Fri - busy 09:30-10:00
p3. Aiko - Asia/Tokyo (UTC+9) - hours 08:00-19:00 Mon-Fri - busy none
p4. Dev - Asia/Kolkata (UTC+5:30) - hours 10:00-19:00 Mon-Fri - busy none - optional
Tue 3 Nov (the organizer's dates, Europe/London)

The page warns: "No time works for everyone required; the closest slots each miss one person."

The candidate slots (4 people, 3 required; no start time works for everyone required; 3 closest slots sent, each missing one person), each with every person's local time; the page's own fairest is s1 (SlotKit.best):

SlotStart (UTC)PriyaMarcoAikoDevEveryone required free
s1Tue 3 Nov 09:15 UTCTue 09:15-10:00 (45 min in their first or last hour)Tue 04:15-05:00 (not free: 4 h 45 min before their hours start)Tue 18:15-19:00 (45 min in their first or last hour)Tue 14:45-15:30no - misses Marco
s2Tue 3 Nov 15:30 UTCTue 15:30-16:15Tue 10:30-11:15Wed 00:30-01:15 (not free: 6 h 15 min after their hours end)Tue 21:00-21:45 (not free: 2 h 45 min after their hours end)no - misses Aiko
s3Tue 3 Nov 16:45 UTCTue 16:45-17:30 (45 min in their first or last hour)Tue 11:45-12:30Wed 01:45-02:30 (not free: 6 h 15 min before their hours start)Tue 22:15-23:00 (not free: 4 h after their hours end)no - misses Aiko

Input:

{
 "task": "slots",
 "meeting": "Launch readiness sync - 45 minutes",
 "dates": "Tue 3 Nov (the organizer's dates, Europe/London)",
 "people": "p1. Priya (organizer) - Europe/London (UTC+0) - hours 09:00-17:30 Mon-Fri - busy 10:00-11:00, 14:00-15:30\np2. Marco - America/New_York (UTC-5) - hours 09:00-17:00 Mon-Fri - busy 09:30-10:00\np3. Aiko - Asia/Tokyo (UTC+9) - hours 08:00-19:00 Mon-Fri - busy none\np4. Dev - Asia/Kolkata (UTC+5:30) - hours 10:00-19:00 Mon-Fri - busy none - optional",
 "slots": "s1. Tue 3 Nov 09:15 UTC | Priya Tue 09:15-10:00 (45 min in their first or last hour); Marco Tue 04:15-05:00 (not free: 4 h 45 min before their hours start); Aiko Tue 18:15-19:00 (45 min in their first or last hour); Dev Tue 14:45-15:30 | missing required: Marco\ns2. Tue 3 Nov 15:30 UTC | Priya Tue 15:30-16:15; Marco Tue 10:30-11:15; Aiko Wed 00:30-01:15 (not free: 6 h 15 min after their hours end); Dev Tue 21:00-21:45 (not free: 2 h 45 min after their hours end) | missing required: Aiko | optional not free: Dev\ns3. Tue 3 Nov 16:45 UTC | Priya Tue 16:45-17:30 (45 min in their first or last hour); Marco Tue 11:45-12:30; Aiko Wed 01:45-02:30 (not free: 6 h 15 min before their hours start); Dev Tue 22:15-23:00 (not free: 4 h after their hours end) | missing required: Aiko | optional not free: Dev",
 "counts": "4 people, 3 required; no start time works for everyone required; 3 closest slots sent, each missing one person",
 "prefs": "Aiko usually joins our calls late in her evening - spare her that if we can"
}

Reply:

{
 "task": "slots",
 "verdict": "near",
 "picks": [
  {
   "slot": "s1",
   "why": "This suits Dev at 14:45, but stretches Priya at 09:15 and Aiko at 18:15, while Marco cannot join at this time.",
   "ask": "Marco, could you send an update or ask someone to join in your place?"
  },
  {
   "slot": "s2",
   "why": "This suits Priya at 15:30 and Marco at 10:30, but Aiko cannot join then, and Dev is unavailable at 21:00.",
   "ask": "Aiko, could you send an update or ask someone to join in your place?"
  },
  {
   "slot": "s3",
   "why": "This suits Marco at 11:45, but stretches Priya at 16:45, while Aiko cannot join then and Dev is unavailable at 22:15.",
   "ask": "Aiko, could you send an update or ask someone to join in your place?"
  }
 ],
 "invite": {
  "subject": "Launch readiness sync - 45 minutes",
  "body": "This is the Launch readiness sync, 45 minutes, on Tue 3 Nov. Priya is at 09:15-10:00, Dev is at 14:45-15:30, and Aiko is at 18:15-19:00, each in their own local time."
 },
 "note": "Marco would need to move, as the top pick falls 4 h 45 min before his hours start, so he may prefer to send someone in his place."
}

The picks the page trusts (ReplyKit.trusted), best first, with the time from the page's own search:

The verdict: near - "Someone would have to move"; it agrees with the first pick (Tue 3 Nov 09:15 UTC), which misses Marco.

The invite: Launch readiness sync - 45 minutes - "This is the Launch readiness sync, 45 minutes, on Tue 3 Nov. Priya is at 09:15-10:00, Dev is at 14:45-15:30, and Aiko is at 18:15-19:00, each in their own local time."

The note: "Marco would need to move, as the top pick falls 4 h 45 min before his hours start, so he may prefer to send someone in his place."

The page's check (ReplyKit.check): kept.

No notes.

A design review over three days

The form: Design review of the onboarding flow, 60 minutes, searched on 3 weekdays from Tue 3 Nov; preferences: Not on Friday afternoon for Lena. The people as typed:

Lena | Berlin | 9-17 | busy Tue 11-12, Wed 14-16
Tomas | Lisbon | 9:30-18 | busy 13-14
Grace | Toronto | 8-16 | busy Wed 9-10:30, Thu 8-12
Omar | Dubai | 9-18 | optional

The people as the page reads them, each with their time zone and its UTC offset at noon on the first date (Lena - Europe/Berlin, UTC+1; Tomas - Europe/Lisbon, UTC+0; Grace - America/Toronto, UTC-5; Omar - Asia/Dubai, UTC+4, optional), and the dates searched (SlotKit.buildInput):

p1. Lena (organizer) - Europe/Berlin (UTC+1) - hours 09:00-17:00 Mon-Fri - busy Tue 11:00-12:00, Wed 14:00-16:00
p2. Tomas - Europe/Lisbon (UTC+0) - hours 09:30-18:00 Mon-Fri - busy 13:00-14:00
p3. Grace - America/Toronto (UTC-5) - hours 08:00-16:00 Mon-Fri - busy Wed 09:00-10:30, Thu 08:00-12:00
p4. Omar - Asia/Dubai (UTC+4) - hours 09:00-18:00 Mon-Fri - busy none - optional
Tue 3 Nov, Wed 4 Nov, Thu 5 Nov (the organizer's dates, Europe/Berlin)

The candidate slots (4 people, 3 required; 5 start times work for everyone required; 2 sent), each with every person's local time; the page's own fairest is s1 (SlotKit.best):

SlotStart (UTC)LenaTomasGraceOmarEveryone required free
s1Tue 3 Nov 14:00 UTCTue 15:00-16:00Tue 14:00-15:00Tue 09:00-10:00Tue 18:00-19:00 (not free: 1 h after their hours end)yes
s2Tue 3 Nov 15:00 UTCTue 16:00-17:00 (60 min in their first or last hour)Tue 15:00-16:00Tue 10:00-11:00Tue 19:00-20:00 (not free: 2 h after their hours end)yes

Input:

{
 "task": "slots",
 "meeting": "Design review of the onboarding flow - 60 minutes",
 "dates": "Tue 3 Nov, Wed 4 Nov, Thu 5 Nov (the organizer's dates, Europe/Berlin)",
 "people": "p1. Lena (organizer) - Europe/Berlin (UTC+1) - hours 09:00-17:00 Mon-Fri - busy Tue 11:00-12:00, Wed 14:00-16:00\np2. Tomas - Europe/Lisbon (UTC+0) - hours 09:30-18:00 Mon-Fri - busy 13:00-14:00\np3. Grace - America/Toronto (UTC-5) - hours 08:00-16:00 Mon-Fri - busy Wed 09:00-10:30, Thu 08:00-12:00\np4. Omar - Asia/Dubai (UTC+4) - hours 09:00-18:00 Mon-Fri - busy none - optional",
 "slots": "s1. Tue 3 Nov 14:00 UTC | Lena Tue 15:00-16:00; Tomas Tue 14:00-15:00; Grace Tue 09:00-10:00; Omar Tue 18:00-19:00 (not free: 1 h after their hours end) | optional not free: Omar\ns2. Tue 3 Nov 15:00 UTC | Lena Tue 16:00-17:00 (60 min in their first or last hour); Tomas Tue 15:00-16:00; Grace Tue 10:00-11:00; Omar Tue 19:00-20:00 (not free: 2 h after their hours end) | optional not free: Omar",
 "counts": "4 people, 3 required; 5 start times work for everyone required; 2 sent",
 "prefs": "Not on Friday afternoon for Lena"
}

Reply:

{
 "task": "slots",
 "verdict": "found",
 "picks": [
  {
   "slot": "s1",
   "why": "Lena at 15:00-16:00, Tomas at 14:00-15:00, and Grace at 09:00-10:00 all land in comfortable hours, though Omar is not free at 18:00-19:00.",
   "ask": ""
  },
  {
   "slot": "s2",
   "why": "Tomas at 15:00-16:00 and Grace at 10:00-11:00 sit comfortably; Lena's 16:00-17:00 falls in her first or last hour, and Omar is not free at 19:00-20:00.",
   "ask": ""
  }
 ],
 "invite": {
  "subject": "Design review of the onboarding flow",
  "body": "This is a design review of the onboarding flow. For Lena it is Tue 15:00-16:00, for Tomas Tue 14:00-15:00, and for Grace Tue 09:00-10:00."
 },
 "note": "5 start times work for everyone required, and the earliest, Tue 3 Nov 14:00 UTC, wins because it keeps everyone outside their first or last hour, unlike the later option."
}

The picks the page trusts (ReplyKit.trusted), best first, with the time from the page's own search:

The verdict: found - "A time works for everyone"; it agrees with the first pick (Tue 3 Nov 14:00 UTC), which has every required person free.

The invite: Design review of the onboarding flow - "This is a design review of the onboarding flow. For Lena it is Tue 15:00-16:00, for Tomas Tue 14:00-15:00, and for Grace Tue 09:00-10:00."

The note: "5 start times work for everyone required, and the earliest, Tue 3 Nov 14:00 UTC, wins because it keeps everyone outside their first or last hour, unlike the later option."

The page's check (ReplyKit.check): kept.

No notes.

A family call on Sunday

The form: Family video call, 60 minutes, searched on Sun 8 Nov; preferences: none. The people as typed:

Sam | London | 18-22 | every day
Nan | Sydney | 7-9 | every day
Leo | Los Angeles | 12-14 | every day
Ava | Lagos | 19-22 | every day | optional

The people as the page reads them, each with their time zone and its UTC offset at noon on the first date (Sam - Europe/London, UTC+0; Nan - Australia/Sydney, UTC+11; Leo - America/Los_Angeles, UTC-8; Ava - Africa/Lagos, UTC+1, optional), and the dates searched (SlotKit.buildInput):

p1. Sam (organizer) - Europe/London (UTC+0) - hours 18:00-22:00 every day - busy none
p2. Nan - Australia/Sydney (UTC+11) - hours 07:00-09:00 every day - busy none
p3. Leo - America/Los_Angeles (UTC-8) - hours 12:00-14:00 every day - busy none
p4. Ava - Africa/Lagos (UTC+1) - hours 19:00-22:00 every day - busy none - optional
Sun 8 Nov (the organizer's dates, Europe/London)

The candidate slots (4 people, 3 required; 5 start times work for everyone required; 2 sent), each with every person's local time; the page's own fairest is s1 (SlotKit.best):

SlotStart (UTC)SamNanLeoAvaEveryone required free
s1Sun 8 Nov 20:00 UTCSun 20:00-21:00Mon 07:00-08:00 (60 min in their first or last hour)Sun 12:00-13:00 (60 min in their first or last hour)Sun 21:00-22:00 (60 min in their first or last hour)yes
s2Sun 8 Nov 21:00 UTCSun 21:00-22:00 (60 min in their first or last hour)Mon 08:00-09:00 (60 min in their first or last hour)Sun 13:00-14:00 (60 min in their first or last hour)Sun 22:00-23:00 (not free: 1 h after their hours end)yes

Input:

{
 "task": "slots",
 "meeting": "Family video call - 60 minutes",
 "dates": "Sun 8 Nov (the organizer's dates, Europe/London)",
 "people": "p1. Sam (organizer) - Europe/London (UTC+0) - hours 18:00-22:00 every day - busy none\np2. Nan - Australia/Sydney (UTC+11) - hours 07:00-09:00 every day - busy none\np3. Leo - America/Los_Angeles (UTC-8) - hours 12:00-14:00 every day - busy none\np4. Ava - Africa/Lagos (UTC+1) - hours 19:00-22:00 every day - busy none - optional",
 "slots": "s1. Sun 8 Nov 20:00 UTC | Sam Sun 20:00-21:00; Nan Mon 07:00-08:00 (60 min in their first or last hour); Leo Sun 12:00-13:00 (60 min in their first or last hour); Ava Sun 21:00-22:00 (60 min in their first or last hour)\ns2. Sun 8 Nov 21:00 UTC | Sam Sun 21:00-22:00 (60 min in their first or last hour); Nan Mon 08:00-09:00 (60 min in their first or last hour); Leo Sun 13:00-14:00 (60 min in their first or last hour); Ava Sun 22:00-23:00 (not free: 1 h after their hours end) | optional not free: Ava",
 "counts": "4 people, 3 required; 5 start times work for everyone required; 2 sent",
 "prefs": "none"
}

Reply:

{
 "task": "slots",
 "verdict": "found",
 "picks": [
  {
   "slot": "s1",
   "why": "Suits Sam at Sun 20:00-21:00; stretches Nan, Leo and Ava into their first or last hour at Mon 07:00-08:00, Sun 12:00-13:00 and Sun 21:00-22:00.",
   "ask": ""
  },
  {
   "slot": "s2",
   "why": "Stretches Sam at Sun 21:00-22:00, Nan at Mon 08:00-09:00 and Leo at Sun 13:00-14:00 alike, all in their first or last hour.",
   "ask": ""
  }
 ],
 "invite": {
  "subject": "Family video call - 60 minutes",
  "body": "Family video call, 60 minutes. Times: Sam Sun 20:00-21:00, Nan Mon 07:00-08:00, Leo Sun 12:00-13:00, Ava Sun 21:00-22:00."
 },
 "note": "5 start times work for everyone required, and this one wins because Ava, who is optional, is free too, unlike the other one."
}

The picks the page trusts (ReplyKit.trusted), best first, with the time from the page's own search:

The verdict: found - "A time works for everyone"; it agrees with the first pick (Sun 8 Nov 20:00 UTC), which has every required person free.

The invite: Family video call - 60 minutes - "Family video call, 60 minutes. Times: Sam Sun 20:00-21:00, Nan Mon 07:00-08:00, Leo Sun 12:00-13:00, Ava Sun 21:00-22:00."

The note: "5 start times work for everyone required, and this one wins because Ava, who is optional, is free too, unlike the other one."

The page's check (ReplyKit.check): kept.

No notes.

The invite file for the first sample

What SlotKit.toICS(t.picks[0].slot.t, built.f.len, {uid, subject, body: ReplyKit.inviteText(built, reply, t)}) writes for "London, New York and Tokyo" above: one event at the first trusted pick, in UTC, 19 lines (lines end in CRLF; commas, semicolons and line breaks in the text are escaped; lines over 75 bytes are folded onto a continuation line that starts with a space). The page stamps DTSTAMP with the time of the download; here it is fixed so the sample stays the same.

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SkillSafe//Fair Hour//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
UID:fair-hour-example-1793697300000@skillsafe.ai
DTSTAMP:20261102T090000Z
DTSTART:20261103T091500Z
DTEND:20261103T100000Z
SUMMARY:Launch readiness sync - 45 minutes
DESCRIPTION:Launch readiness sync - 45 minutes\n\nThis is the Launch readin
 ess sync\, 45 minutes\, on Tue 3 Nov. Priya is at 09:15-10:00\, Dev is at 
 14:45-15:30\, and Aiko is at 18:15-19:00\, each in their own local time.\n
 \nPriya (Europe/London): Tue 09:15-10:00\nMarco (America/New_York): Tue 04
 :15-05:00 - not free (4 h 45 min before their hours start)\nAiko (Asia/Tok
 yo): Tue 18:15-19:00\nDev (Asia/Kolkata): Tue 14:45-15:30\n
END:VEVENT
END:VCALENDAR

Build the input yourself

Load the engine in node (global.window = global; require("./slotkit.js"); require("./replykit.js")), then:

const K = SlotKit, R = ReplyKit;
const form = K.form({ people: "Priya | London | 9-17\nAiko | Tokyo | 8-19",
  title: "Weekly sync", date: "2026-11-03", days: "1", length: "30", prefs: "" });
// form.people is the list as read: [{id, name, zone, start, end, busy, optional, every}], hours as minutes of their own day
const built = K.buildInput(form);     // built.ok is false, with built.note, until two people and a date leave a slot
// built.slots: the candidates [{id, t, ok, missing, optOut, local, edges}], t in UTC milliseconds; built.near: none fits all
K.best(built)                         // the page's own fairest slot, no run needed
const wire = K.toWire(built.input);   // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// const t = R.trusted(reply, built)      // {picks, dropped}: up to three slots the page sent, best first
// R.rows(built.f, t.picks[0].slot)       // every person's local time for the first pick
// R.check(reply, built)                   // {status: kept or flagged, notes}
// K.toICS(t.picks[0].slot.t, built.f.len, { subject: reply.invite.subject, body: R.inviteText(built, reply, t) })

Form keys: people (one person per line, the organizer first), title (what the meeting is for, or empty), date (YYYY-MM-DD in the organizer's time zone, or empty for their today), days (1, 3 or 5 weekdays), length (30, 45, 60 or 90 minutes) and prefs (the organizer's preferences, or empty).

Step 1 — a tiny client helper

One function that sends JSON and unwraps the envelope. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://fair-hour.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":"fair-hour"}' | 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, the bare object (never wrapped in {"input": ...}). 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": "slots", "meeting": "Weekly sync - 30 minutes", "dates": "Tue 3 Nov (the organizer's dates, Europe/London)", "people": "p1. Priya (organizer) - Europe/London (UTC+0) - hours 09:00-17:00 Mon-Fri - busy none\np2. Aiko - Asia/Tokyo (UTC+9) - hours 08:00-19:00 Mon-Fri - busy none", "slots": "s1. Tue 3 Nov 09:00 UTC | Priya Tue 09:00-09:30 (30 min in their first or last hour); Aiko Tue 18:00-18:30 (30 min in their first or last hour)", "counts": "2 people, 2 required; 3 start times work for everyone required; 1 sent", "prefs": "none"}
# - see the field table
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 fair-hour:<task>:<inputHash>:a<attempt> (the one task is slots), and its one reformat retry (with retry_note added) is attempt 2, a new key and a second run. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="fair-hour:slots:$(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}'
jq -r '.verdict, (.picks[] | .slot), .invite.subject' reply.json

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 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: fair-hour:slots:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json