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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, meeting, dates, people, slots, counts, prefs) is missing. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: the slots run is metered and needs a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field, 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.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always slots. |
meeting | yes | What the meeting is for and how long it is: Launch readiness sync - 45 minutes. |
dates | yes | The dates searched, in the organizer's time zone: Tue 3 Nov (the organizer's dates, Europe/London). |
people | yes | One 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. |
slots | yes | One 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. |
counts | yes | How 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. |
prefs | yes | The 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_note | no | Only 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": "..."}
| Field | What it holds |
|---|---|
verdict | found (the first pick has every required person free) or near (someone required would have to move). |
picks[].slot | The id of a slot the page sent (s1 ...); one to three picks, best first. |
picks[].why | One sentence: who the slot suits and who it stretches, in their local times. |
picks[].ask | For 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.subject | The invite's subject, at most 10 words, from the meeting. |
invite.body | The 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. |
note | One 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):
| Slot | Start (UTC) | Priya | Marco | Aiko | Dev | Everyone required free |
|---|---|---|---|---|---|---|
s1 | Tue 3 Nov 09:15 UTC | Tue 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:30 | no - misses Marco |
s2 | Tue 3 Nov 15:30 UTC | Tue 15:30-16:15 | Tue 10:30-11:15 | Wed 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 |
s3 | Tue 3 Nov 16:45 UTC | Tue 16:45-17:30 (45 min in their first or last hour) | Tue 11:45-12:30 | Wed 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:
s1Tue 3 Nov 09:15 UTC, misses Marco - "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?"- Priya (Europe/London): Tue 09:15-10:00 (45 min in their first or last hour)
- Marco (America/New_York): Tue 04:15-05:00 (not free: 4 h 45 min before their hours start)
- Aiko (Asia/Tokyo): Tue 18:15-19:00 (45 min in their first or last hour)
- Dev (Asia/Kolkata): Tue 14:45-15:30
s2Tue 3 Nov 15:30 UTC, misses Aiko - "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?"- Priya (Europe/London): Tue 15:30-16:15
- Marco (America/New_York): Tue 10:30-11:15
- Aiko (Asia/Tokyo): Wed 00:30-01:15 (not free: 6 h 15 min after their hours end)
- Dev (Asia/Kolkata): Tue 21:00-21:45 (not free: 2 h 45 min after their hours end)
s3Tue 3 Nov 16:45 UTC, misses Aiko - "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?"- Priya (Europe/London): Tue 16:45-17:30 (45 min in their first or last hour)
- Marco (America/New_York): Tue 11:45-12:30
- Aiko (Asia/Tokyo): Wed 01:45-02:30 (not free: 6 h 15 min before their hours start)
- Dev (Asia/Kolkata): Tue 22:15-23:00 (not free: 4 h after their hours end)
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):
| Slot | Start (UTC) | Lena | Tomas | Grace | Omar | Everyone required free |
|---|---|---|---|---|---|---|
s1 | Tue 3 Nov 14:00 UTC | Tue 15:00-16:00 | Tue 14:00-15:00 | Tue 09:00-10:00 | Tue 18:00-19:00 (not free: 1 h after their hours end) | yes |
s2 | Tue 3 Nov 15:00 UTC | Tue 16:00-17:00 (60 min in their first or last hour) | Tue 15:00-16:00 | Tue 10:00-11:00 | Tue 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:
s1Tue 3 Nov 14:00 UTC, everyone required free - "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."- Lena (Europe/Berlin): Tue 15:00-16:00
- Tomas (Europe/Lisbon): Tue 14:00-15:00
- Grace (America/Toronto): Tue 09:00-10:00
- Omar (Asia/Dubai): Tue 18:00-19:00 (not free: 1 h after their hours end)
s2Tue 3 Nov 15:00 UTC, everyone required free - "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."- Lena (Europe/Berlin): Tue 16:00-17:00 (60 min in their first or last hour)
- Tomas (Europe/Lisbon): Tue 15:00-16:00
- Grace (America/Toronto): Tue 10:00-11:00
- Omar (Asia/Dubai): Tue 19:00-20:00 (not free: 2 h after their hours end)
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):
| Slot | Start (UTC) | Sam | Nan | Leo | Ava | Everyone required free |
|---|---|---|---|---|---|---|
s1 | Sun 8 Nov 20:00 UTC | Sun 20:00-21:00 | Mon 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 |
s2 | Sun 8 Nov 21:00 UTC | Sun 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:
s1Sun 8 Nov 20:00 UTC, everyone required free - "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."- Sam (Europe/London): Sun 20:00-21:00
- Nan (Australia/Sydney): Mon 07:00-08:00 (60 min in their first or last hour)
- Leo (America/Los_Angeles): Sun 12:00-13:00 (60 min in their first or last hour)
- Ava (Africa/Lagos): Sun 21:00-22:00 (60 min in their first or last hour)
s2Sun 8 Nov 21:00 UTC, everyone required free - "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."- Sam (Europe/London): Sun 21:00-22:00 (60 min in their first or last hour)
- Nan (Australia/Sydney): Mon 08:00-09:00 (60 min in their first or last hour)
- Leo (America/Los_Angeles): Sun 13:00-14:00 (60 min in their first or last hour)
- Ava (Africa/Lagos): Sun 22:00-23:00 (not free: 1 h after their hours end)
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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://fair-hour.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// fair-hour.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://fair-hour.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://fair-hour.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// FairHour.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class FairHour {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://fair-hour.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://fair-hour.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://fair-hour.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://fair-hour.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"fair-hour"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "fair-hour"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "fair-hour" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "fair-hour"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "fair-hour"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "fair-hour" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "fair-hour"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "fair-hour" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, 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}'
inp = json.load(open("input.json")) # {"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
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "slots", meeting, dates, people, slots, counts, prefs} (see the field table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses 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
import hashlib, time
key = "fair-hour:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
for p in reply.get("picks") or []:
print(p["slot"])
print((reply.get("invite") or {}).get("subject", ""))
import { createHash } from "node:crypto";
const key = `fair-hour:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
for (const p of reply.picks || []) console.log(p.slot);
console.log((reply.invite || {}).subject);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "fair-hour:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
picks, _ := reply["picks"].([]any)
fmt.Println(j["charged_credits"], j["truncated"], reply["verdict"])
for _, x := range picks {
p := x.(map[string]any)
fmt.Println(p["slot"])
}
invite, _ := reply["invite"].(map[string]any)
fmt.Println(invite["subject"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "fair-hour:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.get("verdict"));
for (JsonNode p : reply.path("picks")) System.out.println(p.path("slot").asText());
System.out.println(reply.path("invite").path("subject").asText());
require "digest"
key = "fair-hour:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["verdict"]].inspect
(reply["picks"] || []).each { |p| puts p["slot"] }
puts reply.dig("invite", "subject")
$key = "fair-hour:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", ($reply["verdict"] ?? ""), "\n";
foreach ($reply["picks"] ?? [] as $p) echo $p["slot"], "\n";
echo $reply["invite"]["subject"] ?? "", "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"fair-hour:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply!["verdict"]}");
foreach (var p in reply["picks"] as JsonArray ?? new JsonArray()) Console.WriteLine($"{p!["slot"]}");
Console.WriteLine($"{reply["invite"]?["subject"]}");
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "fair-hour:slots:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `fair-hour:slots:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "fair-hour:slots:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "fair-hour:slots:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "fair-hour:slots:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: fair-hour:slots:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"fair-hour:slots:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);