← On the Calendar
API

Run On the Calendar from your own code

The one metered task, tidy, is available over HTTP. It takes a schedule as the page has read it - today's date and weekday, the time zone, how the sheet is laid out (its format and columns, the date order the page settled on and why, the default length of an event and up to six rows it read cleanly), the unclear rows, one per entry with what the page read and why the row is unclear, and the facts about the whole sheet - and returns an answer for every unclear row: an event it settles (title, date, start, end, all day, place and why), one plain question only the person can answer, or a skip for a row that is not an event; plus a verdict (ready or ask) and a note.

The division of labour first: the page reads every row - tab-, comma- or pipe-separated with a header, or plain lines - into an event, puts every row it is sure of straight onto a month calendar and into the files, and sends only the rows it cannot be sure of; the run settles those. The page reads up to 300 rows and sends at most 60 unclear rows a run. After the reply it checks that every row it sent is answered exactly once, and holds each settled event to the row: a real date within two years of today, clock times with the end after the start, all day exactly when there is no start, a weekday that agrees with any weekday the row names, the day "next" or "this" weekday points at counted from today, and only the row's own words in the title and the place. It warns when a sentence quotes a number the input does not have. Settled rows join the calendar; a row the run asks about stays out of the file until it is answered. Times go into the .ics file in UTC, converted through the browser's own time-zone data. Nothing is sent to any calendar - download the .ics file or the Google Calendar CSV and import it yourself. Your code can do the same with the engine files the page loads (see "Build the input yourself"). On the Calendar is not affiliated with or endorsed by Google; the CSV is in Google Calendar's import format.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"on-the-calendar"}), 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 key (task, today, timezone, sheet, rows, facts) 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 tidy 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: tidy (the one task is tidy); a missing or different one is still answered as tidy. Every value is a string. What each key holds, in the words of the system prompt, with the first sample's value (its first line where it has several):

KeyRequiredWhat it holds
taskyesAlways tidy. If it is missing or different, still answer this task and set "task": "tidy".
todayyesToday's date and weekday, as YYYY-MM-DD (Weekday). First sample: 2026-11-02 (Monday).
timezoneyesThe time zone the times are in. First sample: America/Chicago.
sheetyesHow the sheet is laid out: its format and columns, the date order the page settled on and why, the default length of an event, and up to six rows it read cleanly, each with what it read. First sample: Format: tab-separated; columns: Event | Date | Time | Location | Notes ...
rowsyesThe unclear rows, one per entry: r3. then the row's cells as Column: value | ... (or the line as written), then what the page read and why the row is unclear. First sample: r3. Event: Social | Date: 11/14 | Time: after the run | Location: TBD ...
factsyesThe counts and anything the page noticed about the whole sheet. First sample: 7 rows read, 5 clear, 2 unclear rows.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

How the page reads a paste (CalKit.form): up to 200,000 characters and the first 300 rows. The table (CalKit.readTable): the first line picks the separator - a tab, else a pipe (with at least two of them), else a comma; quoted cells may hold the separator, doubled quotes and line breaks. A first row with at least two column names the page knows (or a title column) is the header; with no separator, or no such header, every line is one event written in words. Dates (CalKit.parseDate): ISO (2026-11-18), numeric (11/7, 18/11/2026, 8.11.26), month names (14 Nov, November 14, 2026), and today, tonight and tomorrow. A date with no year is the next one on or after 90 days ago. A numeric date is read month-first or day-first by the column's own evidence - a day over 12 settles the order for the whole sheet - and when the sheet does not settle it, by a guess from the time zone (month-first for America/..., day-first otherwise), in which case a date that reads either way is flagged; you can also set the order. Times (CalKit.parseTimes): 7am, 19:00, ranges such as 6:30-7:30pm or 2-4pm, noon, midday, midnight, all day; a row with no time at all (in plain lines, or an empty time cell) is all day. Lengths: an end column, a duration column (CalKit.parseDuration), or else the default length (60 minutes unless you change it); an evening start whose end is before 6am ends the next day.

The columns the page recognises

A header cell is matched in lower case, with _, *, : and # read as spaces (CalKit.COLS). The first column of each kind wins.

KindHeader namesWhat the page takes from it
titletitle, summary, event, event name, name, what, subject, activity, session, class, item, description of eventthe event's title
datedate, day, when, event date, start date, onthe date (a date and time together are split)
enddateend date, to date, until date, finish datethe last day of a multi-day all-day event
startstart, start time, time, from, begins, starts, start at, kick offthe start time, or a range such as 6:30-7:30pm
endend, end time, to, until, finish, finishes, endsthe end time
durationduration, length, mins, minutes, hours, hrs, how longthe length, such as 90, 1:30 or 1h 30m
locationlocation, where, venue, place, address, room, meeting pointthe place
descriptiondescription, notes, details, info, agenda, comments, what to bringnotes, kept as the event's description
attendeesattendees, guests, emails, email, invite, invitees, people, whoguests: every email address in the cell
alldayall day, all-day, allday, all day eventall day when the cell is y, yes, true, 1 or x

What makes a row unclear

Each row is read on its own (CalKit.readRow). A row with any issue is unclear and is sent; a fix issue keeps it off the calendar until the run settles it, a check issue marks a reading the run should confirm. Every row with no issue is clear: it goes straight onto the calendar and into the files.

CodeLevelWhen
ambiguous_datecheckA numeric date whose day and month could be read either way, when the sheet itself does not settle the order
weekday_mismatchfixThe row names a weekday that is not the weekday of its date
relative_datefixA day named relative to another (next or this weekday, next week, the weekend, the 3rd) rather than a date
no_datefixA date cell (or line) the page cannot read as a date
missing_datefixNo date at all
time_tbdfixThe time is TBD, TBC, TBA or ?
early_hourcheckA time from 1 to 5 written without am or pm (read as written, early in the morning)
no_ampmcheckA bare hour such as 9 in a time column, with no am or pm
vague_timefixA time the row only describes - morning, after work, lunch, 3ish
no_timefixA time cell the page cannot read as a time
no_endcheckAn end cell that is not a time (the default length is used)
end_before_startfixAn end before the start (an evening start with an end before 6am is read as ending the next day instead)
no_emailscheckA guests cell with no email address in it, so no invitation goes out
no_titlefixNo title
place_tbdcheckThe place is TBD, TBC, TBA or ? (written as no place)

Across the sheet (CalKit.form and CalKit.clashes), sent in facts: no column read as the title (no_title_column, a check) or as the date (no_date_column, a fix); dates that mix month-first and day-first (mixed_order, a check); more than 300 rows (too_many); the same title, date and start twice (duplicate, a check); and two timed events with different titles that share minutes (overlap, a note) - overlaps are found in UTC, so they hold across a clock change. CalKit.buildInput writes sheet (the format and columns, the date order and why, the default length and up to six clean rows with what the page read), one entry per unclear row into rows (its id, its cells as Column: value, what the page read and why it is unclear), and the counts and sheet findings into facts. When every row is clear there is nothing to send and no run.

The model's rules

As the system prompt states them, in order:

  1. Answer every row in rows exactly once: as an entry in events (settled), in questions (only the person can settle it) or in skip (the row is not an event - a heading, a note, a blank).
  2. events - each { "row": its id, "title", "date": "YYYY-MM-DD", "start": "HH:MM" 24-hour or "", "end": "HH:MM" or "", "all_day": true or false, "location", "why" }.
    • Settle a row only from its own words, today and the sheet's habits (its date order, its clean rows, its default length). Never guess beyond them.
    • "next Saturday" (or any "next" weekday) means the first such day after today; "this Saturday" means the first such day on or after today; "tomorrow" is the day after today.
    • A day and month that could be read either way: settle it from the sheet's habits - the date order, or a weekday the row names - and say how in why.
    • Keep what the page read unless the row's own words say otherwise; when you change a date or time the page read, say why.
    • A time the row only describes ("after work", "morning", "3ish"), a date it does not give, or a weekday that contradicts its date: ask, do not choose.
    • all_day is true exactly when start is "". end may be "" (the page then uses the default length); when given, it is after start.
    • title and location use only words from the row. A place written as TBD, TBC or ? is "".
    • why - one sentence, at most 20 words: what was unclear and how the row settles it.
  3. questions - each { "row": its id, "question": one plain question the person can answer in a word or a time, at most 25 words }.
  4. skip - each { "row": its id, "why": one sentence }.
  5. verdict - ask when questions has any entry; otherwise ready.
  6. note - one sentence, at most 30 words: how many rows are settled and what to answer next.

And the house rules:

The reply

One JSON object, in the shape the system prompt asks for:

{
  "task": "tidy",
  "verdict": "ready | ask",
  "events": [ { "row": "r3", "title": "", "date": "YYYY-MM-DD", "start": "HH:MM", "end": "", "all_day": false, "location": "", "why": "one sentence" } ],
  "questions": [ { "row": "r5", "question": "one question" } ],
  "skip": [ { "row": "r9", "why": "one sentence" } ],
  "note": "one sentence"
}

The page reads the reply with ReplyKit.normalize: the verdict matched in upper or lower letters (anything other than ready or ask reads as unknown), a row id such as 3 or row 3 read as r3, every events entry trimmed to its eight keys (all_day true for true or yes), and a question with no text or an entry with no row dropped.

ReplyKit.trusted(reply, built) decides what the page takes from it. An answer for a row the page did not send, or a second answer for one row, is refused. A settled event is set aside, and the row stays unclear, when its date is not a real YYYY-MM-DD date or is more than two years from today, when it has no title, when start or end is not HH:MM, when all_day is not exactly "no start", or when it ends at or before its start. It is kept but warned about when the date's weekday is not the weekday the row names, when the row says "next" or "this" a weekday and the date is not the day that points at counted from today (ReplyKit.relativeDay: next is the first such day after today, this the first on or after), and when the title or place has a word of three letters or more the row does not have; moving the page's own date or start is noted.

ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one that does not match the answers (ask exactly when a question is kept); every problem above; the rows left unanswered (a note - they stay unclear); and in every sentence (the note, each why and each question) a number that is not in the input, backticks or retry_note. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "Ready to import" (ready), "A few questions first" (ask).

What the page builds from it: ReplyKit.merge(form, built, reply) is the calendar - clear rows as the page read them, settled rows as the run settled them (keeping the row's notes and guests, with the default length when end is empty), asked rows with their question. CalView.months draws a month grid for every month the schedule touches, each event a chip on its day marked read, settled, question or unclear, and CalView.undated lists the rows with no date yet. Only rows that are clear or settled, with a title, a date and an all-day flag or a start and end, go into the files: CalKit.ics(events, {tz, stamp, name}) writes an iCalendar file - timed events in UTC through CalKit.toUtc, all-day events as dates ending the next day, text escaped and lines folded at 75 octets with CRLF ends as RFC 5545 asks, guests as attendees - and CalKit.gcalCSV(events) the CSV Google Calendar imports (Subject, Start Date, Start Time, End Date, End Time, All Day Event, Description, Location, with US dates and 12-hour times). ReplyKit.md and ReplyKit.json write the merged schedule row by row.

Worked examples - every sample

These are the free samples on the page: the rows as pasted, with the time zone and today each sample fixes, how the page reads every row - its date, time, title and any issue - the exact input it sends (only the unclear rows), the reply that replays for free, and what the page does with it - what it trusts, the calendar after the merge, the verdict and its check. Together they cover the verdicts ask and ready.

A running club's sheet

The time zone: America/Chicago; today: 2026-11-02 (Mon); default length: 60 minutes; date order: auto.

The rows as pasted:

Event	Date	Time	Location	Notes
Long run	Sat 11/7	7am	Riverside Park	10 miles, water at mile 5
Track night	11/10	6:30-7:30pm	North High track	Bring spikes
Social	11/14	after the run	TBD	
Hill repeats	11/17/2026	6:30pm	Grant Hill	8 x 90 seconds
Trail day	next Saturday	8am	Wolf Creek trailhead	Carpool from the club
Long run	Sat 11/21	7am	Riverside Park	12 miles
Turkey trot	Thu 11/26	8:30am	City Hall	Race day - 5K

The page reads it as a tab-separated table with the columns Event, Date, Time, Location, Notes. Dates read month first (4 dates in the sheet can only be month-first). 7 rows read, 5 clear, 2 unclear rows sent.

Every row as the page reads it (CalKit.readRow; status ok is clear, check and unread are sent):

RowDateTimeTitleStatusIssues
r12026-11-07 Sat07:00-08:00Long runok-
r22026-11-10 Tue18:30-19:30Track nightok-
r32026-11-14 Sat-Socialunreadvague_time (fix): "after the run" is not a clock time.; place_tbd (check): The place is to be decided.
r42026-11-17 Tue18:30-19:30Hill repeatsok-
r5-08:00-09:00Trail dayunreadrelative_date (fix): "next Saturday" names a day relative to another, not a date.
r62026-11-21 Sat07:00-08:00Long runok-
r72026-11-26 Thu08:30-09:30Turkey trotok-

Input (it carries only the 2 unclear rows):

{
 "task": "tidy",
 "today": "2026-11-02 (Monday)",
 "timezone": "America/Chicago",
 "sheet": "Format: tab-separated; columns: Event | Date | Time | Location | Notes\nDate order: month first (4 dates in the sheet can only be month-first)\nDefault length: 60 minutes\nRows the page read cleanly (5 in all), for the sheet's habits:\nr1. Event: Long run | Date: Sat 11/7 | Time: 7am | Location: Riverside Park | Notes: 10 miles, water at mile 5 -> 2026-11-07 07:00-08:00\nr2. Event: Track night | Date: 11/10 | Time: 6:30-7:30pm | Location: North High track | Notes: Bring spikes -> 2026-11-10 18:30-19:30\nr4. Event: Hill repeats | Date: 11/17/2026 | Time: 6:30pm | Location: Grant Hill | Notes: 8 x 90 seconds -> 2026-11-17 18:30-19:30\nr6. Event: Long run | Date: Sat 11/21 | Time: 7am | Location: Riverside Park | Notes: 12 miles -> 2026-11-21 07:00-08:00\nr7. Event: Turkey trot | Date: Thu 11/26 | Time: 8:30am | Location: City Hall | Notes: Race day - 5K -> 2026-11-26 08:30-09:30",
 "rows": "r3. Event: Social | Date: 11/14 | Time: after the run | Location: TBD\n   the page read date 2026-11-14 (Saturday); unclear: \"after the run\" is not a clock time. The place is to be decided.\nr5. Event: Trail day | Date: next Saturday | Time: 8am | Location: Wolf Creek trailhead | Notes: Carpool from the club\n   the page read 08:00-09:00; unclear: \"next Saturday\" names a day relative to another, not a date.",
 "facts": "7 rows read, 5 clear, 2 unclear rows."
}

Reply:

{
 "task": "tidy",
 "verdict": "ask",
 "events": [
  {
   "row": "r5",
   "title": "Trail day",
   "date": "2026-11-07",
   "start": "08:00",
   "end": "09:00",
   "all_day": false,
   "location": "Wolf Creek trailhead",
   "why": "Next Saturday means the first Saturday after November 2, which is November 7."
  }
 ],
 "questions": [
  {
   "row": "r3",
   "question": "What time does the November 14 social begin, since \"after the run\" isn't a clock reading?"
  }
 ],
 "skip": [],
 "note": "One of two unresolved entries is settled for November 7; answer what time the November 14 social begins to finish the other."
}

What the page trusts (ReplyKit.trusted):

The calendar after the merge (ReplyKit.merge); 6 of 7 rows go into the .ics file and the CSV:

RowDateTimeTitlePlaceFrom
r12026-11-07 Sat07:00-08:00Long runRiverside Parkread by the page; in the files
r22026-11-10 Tue18:30-19:30Track nightNorth High trackread by the page; in the files
r32026-11-14 Sat-Social-question: What time does the November 14 social begin, since "after the run" isn't a clock reading?
r42026-11-17 Tue18:30-19:30Hill repeatsGrant Hillread by the page; in the files
r52026-11-07 Sat08:00-09:00Trail dayWolf Creek trailheadsettled by the run; in the files
r62026-11-21 Sat07:00-08:00Long runRiverside Parkread by the page; in the files
r72026-11-26 Thu08:30-09:30Turkey trotCity Hallread by the page; in the files

The verdict: ask - "A few questions first"; it agrees with the answers.

The note: "One of two unresolved entries is settled for November 7; answer what time the November 14 social begins to finish the other."

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

No notes.

A family's plain lines

The time zone: Europe/London; today: 2026-11-02 (Mon); default length: 60 minutes; date order: auto.

The rows as pasted:

Tomorrow 4:30pm dentist for Mia
Fri 6 Nov school play 6-7.30pm
this Saturday swimming 9am at the leisure centre
Sun 8/11 roast at Gran's 1pm
14 Nov Leo's party 2-4pm
Tue 17 Nov parents' evening 5:15pm
28/11 Christmas market all day

The page reads it as plain lines, one event per line. Dates read day first (1 date in the sheet can only be day-first). 7 rows read, 6 clear, 1 unclear row sent.

Every row as the page reads it (CalKit.readRow; status ok is clear, check and unread are sent):

RowDateTimeTitleStatusIssues
r12026-11-03 Tue16:30-17:30dentist for Miaok-
r22026-11-06 Fri18:00-19:30school playok-
r3-09:00-10:00swimming at the leisure centreunreadrelative_date (fix): "this Saturday swimming 9am at the..." names a day relative to another, not a date.
r42026-11-08 Sun13:00-14:00roast at Gran'sok-
r52026-11-14 Sat14:00-16:00Leo's partyok-
r62026-11-17 Tue17:15-18:15parents' eveningok-
r72026-11-28 Satall dayChristmas market all dayok-

Input (it carries only the 1 unclear row):

{
 "task": "tidy",
 "today": "2026-11-02 (Monday)",
 "timezone": "Europe/London",
 "sheet": "Format: plain lines, one event per line\nDate order: day first (1 date in the sheet can only be day-first)\nDefault length: 60 minutes\nRows the page read cleanly (6 in all), for the sheet's habits:\nr1. Tomorrow 4:30pm dentist for Mia -> 2026-11-03 16:30-17:30\nr2. Fri 6 Nov school play 6-7.30pm -> 2026-11-06 18:00-19:30\nr4. Sun 8/11 roast at Gran's 1pm -> 2026-11-08 13:00-14:00\nr5. 14 Nov Leo's party 2-4pm -> 2026-11-14 14:00-16:00\nr6. Tue 17 Nov parents' evening 5:15pm -> 2026-11-17 17:15-18:15\nr7. 28/11 Christmas market all day -> 2026-11-28 all day",
 "rows": "r3. this Saturday swimming 9am at the leisure centre\n   the page read 09:00-10:00; unclear: \"this Saturday swimming 9am at the...\" names a day relative to another, not a date.",
 "facts": "7 rows read, 6 clear, 1 unclear row."
}

Reply:

{
 "task": "tidy",
 "verdict": "ready",
 "events": [
  {
   "row": "r3",
   "title": "swimming",
   "date": "2026-11-07",
   "start": "09:00",
   "end": "10:00",
   "all_day": false,
   "location": "the leisure centre",
   "why": "This Saturday is relative to another day; it settles to the first Saturday on or after 2026-11-02, 9am kept."
  }
 ],
 "questions": [],
 "skip": [],
 "note": "The 1 unclear row is now settled as an event, so no question is needed."
}

What the page trusts (ReplyKit.trusted):

The calendar after the merge (ReplyKit.merge); 7 of 7 rows go into the .ics file and the CSV:

RowDateTimeTitlePlaceFrom
r12026-11-03 Tue16:30-17:30dentist for Mia-read by the page; in the files
r22026-11-06 Fri18:00-19:30school play-read by the page; in the files
r32026-11-07 Sat09:00-10:00swimmingthe leisure centresettled by the run; in the files
r42026-11-08 Sun13:00-14:00roast at Gran's-read by the page; in the files
r52026-11-14 Sat14:00-16:00Leo's party-read by the page; in the files
r62026-11-17 Tue17:15-18:15parents' evening-read by the page; in the files
r72026-11-28 Satall dayChristmas market all day-read by the page; in the files

The verdict: ready - "Ready to import"; it agrees with the answers.

The note: "The 1 unclear row is now settled as an event, so no question is needed."

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

No notes.

A conference CSV

The time zone: Europe/Berlin; today: 2026-11-02 (Mon); default length: 45 minutes; date order: auto.

The rows as pasted:

Session,Date,Start,End,Room,Speakers
Opening keynote,2026-11-18,09:00,10:00,Hall A,lena.vogt@dataconf.example
Workshop: Testing data pipelines,18/11/2026,10:30,12:00,Room 2,
Lunch,2026-11-18,noon,13:00,Foyer,
Panel: Open data in cities,2026-11-18,2pm,3pm,Hall B,"amir.haddad@dataconf.example, j.ortiz@dataconf.example"
Speakers' dinner,Wed 19 Nov,7pm,,Brauhaus am Markt,
Day 2 keynote,2026-11-19,9,10,Hall A,
Closing remarks,2026-11-19,16:00,15:00,Hall A,

The page reads it as a comma-separated table with the columns Session, Date, Start, End, Room, Speakers. Dates read day first (1 date in the sheet can only be day-first). 7 rows read, 4 clear, 3 unclear rows sent.

Every row as the page reads it (CalKit.readRow; status ok is clear, check and unread are sent):

RowDateTimeTitleStatusIssues
r12026-11-18 Wed09:00-10:00Opening keynoteok-
r22026-11-18 Wed10:30-12:00Workshop: Testing data pipelinesok-
r32026-11-18 Wed12:00-13:00Lunchok-
r42026-11-18 Wed14:00-15:00Panel: Open data in citiesok-
r52026-11-19 Thu19:00-19:45Speakers' dinnerunreadweekday_mismatch (fix): "Wed 19 Nov" says Wed, but 2026-11-19 is a Thu.
r62026-11-19 Thu09:00-10:00Day 2 keynotecheckno_ampm (check): "9" has no am or pm; read as 09:00.
r72026-11-19 Thu16:00-15:00Closing remarksunreadend_before_start (fix): It ends at 15:00, before it starts at 16:00.

Input (it carries only the 3 unclear rows):

{
 "task": "tidy",
 "today": "2026-11-02 (Monday)",
 "timezone": "Europe/Berlin",
 "sheet": "Format: comma-separated; columns: Session | Date | Start | End | Room | Speakers\nDate order: day first (1 date in the sheet can only be day-first)\nDefault length: 45 minutes\nRows the page read cleanly (4 in all), for the sheet's habits:\nr1. Session: Opening keynote | Date: 2026-11-18 | Start: 09:00 | End: 10:00 | Room: Hall A | Speakers: lena.vogt@dataconf.example -> 2026-11-18 09:00-10:00\nr2. Session: Workshop: Testing data pipelines | Date: 18/11/2026 | Start: 10:30 | End: 12:00 | Room: Room 2 -> 2026-11-18 10:30-12:00\nr3. Session: Lunch | Date: 2026-11-18 | Start: noon | End: 13:00 | Room: Foyer -> 2026-11-18 12:00-13:00\nr4. Session: Panel: Open data in cities | Date: 2026-11-18 | Start: 2pm | End: 3pm | Room: Hall B | Speakers: amir.haddad@dataconf.example, j.ortiz@dataconf.example -> 2026-11-18 14:00-15:00",
 "rows": "r5. Session: Speakers' dinner | Date: Wed 19 Nov | Start: 7pm | Room: Brauhaus am Markt\n   the page read date 2026-11-19 (Thursday), 19:00-19:45; unclear: \"Wed 19 Nov\" says Wed, but 2026-11-19 is a Thu.\nr6. Session: Day 2 keynote | Date: 2026-11-19 | Start: 9 | End: 10 | Room: Hall A\n   the page read date 2026-11-19 (Thursday), 09:00-10:00; unclear: \"9\" has no am or pm; read as 09:00.\nr7. Session: Closing remarks | Date: 2026-11-19 | Start: 16:00 | End: 15:00 | Room: Hall A\n   the page read date 2026-11-19 (Thursday), 16:00-15:00; unclear: It ends at 15:00, before it starts at 16:00.",
 "facts": "7 rows read, 4 clear, 3 unclear rows."
}

Reply:

{
 "task": "tidy",
 "verdict": "ask",
 "events": [
  {
   "row": "r6",
   "title": "Day 2 keynote",
   "date": "2026-11-19",
   "start": "09:00",
   "end": "10:00",
   "all_day": false,
   "location": "Hall A",
   "why": "Hour 9 to 10 lacked am or pm; kept as 09:00 to 10:00 like the sheet's morning sessions."
  }
 ],
 "questions": [
  {
   "row": "r5",
   "question": "Is the speakers' dinner on Wednesday 18 November or Thursday 19 November, since 19 Nov 2026 is a Thursday not a Wednesday?"
  },
  {
   "row": "r7",
   "question": "Closing remarks starts at 16:00; what time does it end, since 15:00 is before the start?"
  }
 ],
 "skip": [],
 "note": "3 unclear rows: Day 2 keynote is settled, 2 remain - confirm the dinner's day and the closing remarks' end time."
}

What the page trusts (ReplyKit.trusted):

The calendar after the merge (ReplyKit.merge); 5 of 7 rows go into the .ics file and the CSV:

RowDateTimeTitlePlaceFrom
r12026-11-18 Wed09:00-10:00Opening keynoteHall Aread by the page; in the files
r22026-11-18 Wed10:30-12:00Workshop: Testing data pipelinesRoom 2read by the page; in the files
r32026-11-18 Wed12:00-13:00LunchFoyerread by the page; in the files
r42026-11-18 Wed14:00-15:00Panel: Open data in citiesHall Bread by the page; in the files
r52026-11-19 Thu19:00-19:45Speakers' dinnerBrauhaus am Marktquestion: Is the speakers' dinner on Wednesday 18 November or Thursday 19 November, since 19 Nov 2026 is a Thursday not a Wednesday?
r62026-11-19 Thu09:00-10:00Day 2 keynoteHall Asettled by the run; in the files
r72026-11-19 Thu16:00-15:00Closing remarksHall Aquestion: Closing remarks starts at 16:00; what time does it end, since 15:00 is before the start?

The verdict: ask - "A few questions first"; it agrees with the answers.

The note: "3 unclear rows: Day 2 keynote is settled, 2 remain - confirm the dinner's day and the closing remarks' end time."

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

No notes.

The .ics file for the first sample

What CalKit.ics(events, {tz: "America/Chicago", stamp: Date.UTC(2026, 10, 2), name}) writes for "A running club's sheet" above, after the merge: 6 events, each timed one in UTC (America/Chicago is the time zone), with the row id in its UID. The page stamps the real time; this one is fixed so the sample is the same on every build. Lines end in CRLF.

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SkillSafe//On the Calendar//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:7 events from 2026-11-07 to 2026-11-26
X-WR-TIMEZONE:America/Chicago
BEGIN:VEVENT
UID:dac68e47-r1@on-the-calendar.skillsafe.ai
DTSTAMP:20261102T000000Z
DTSTART:20261107T130000Z
DTEND:20261107T140000Z
SUMMARY:Long run
LOCATION:Riverside Park
DESCRIPTION:10 miles\, water at mile 5
END:VEVENT
BEGIN:VEVENT
UID:bc41e875-r2@on-the-calendar.skillsafe.ai
DTSTAMP:20261102T000000Z
DTSTART:20261111T003000Z
DTEND:20261111T013000Z
SUMMARY:Track night
LOCATION:North High track
DESCRIPTION:Bring spikes
END:VEVENT
BEGIN:VEVENT
UID:b12f686f-r4@on-the-calendar.skillsafe.ai
DTSTAMP:20261102T000000Z
DTSTART:20261118T003000Z
DTEND:20261118T013000Z
SUMMARY:Hill repeats
LOCATION:Grant Hill
DESCRIPTION:8 x 90 seconds
END:VEVENT
BEGIN:VEVENT
UID:3ee3f532-r5@on-the-calendar.skillsafe.ai
DTSTAMP:20261102T000000Z
DTSTART:20261107T140000Z
DTEND:20261107T150000Z
SUMMARY:Trail day
LOCATION:Wolf Creek trailhead
DESCRIPTION:Carpool from the club
END:VEVENT
BEGIN:VEVENT
UID:bb280c03-r6@on-the-calendar.skillsafe.ai
DTSTAMP:20261102T000000Z
DTSTART:20261121T130000Z
DTEND:20261121T140000Z
SUMMARY:Long run
LOCATION:Riverside Park
DESCRIPTION:12 miles
END:VEVENT
BEGIN:VEVENT
UID:f10f3893-r7@on-the-calendar.skillsafe.ai
DTSTAMP:20261102T000000Z
DTSTART:20261126T143000Z
DTEND:20261126T153000Z
SUMMARY:Turkey trot
LOCATION:City Hall
DESCRIPTION:Race day - 5K
END:VEVENT
END:VCALENDAR

Build the input yourself

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

const K = CalKit, R = ReplyKit;
const form = K.form({ rows: "Title\tDate\tTime\tPlace\nBook club\t11/19\t7pm\tMaple Library\nPotluck\tnext Friday\t6:30pm\tCommunity hall\nBoard meeting\t11/24/2026\t18:00\tRoom 4",
  tz: "America/New_York", today: "2026-11-02", order: "auto", dur: "60" });
// form.events: [{id, raw, title, date, start, end, allDay, location, description, attendees, issues, status}] - one per row
//   status: ok (clear), check or unread (sent); issues: [{code, level, text}]
// form.findings: [{code, level, text}] - the sheet's columns, mixed date orders, duplicates and overlaps
// form.order, form.orderWhy: MDY or DMY, and why
const built = K.buildInput(form);     // built.ok is false, with built.note, when no row is unclear
// built.sent: the unclear rows sent (at most 60); built.warn: what the page warns about
const wire = K.toWire(built.input);   // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// R.check(reply, built)                  // {status: kept or flagged, notes}
// R.trusted(reply, built)                // {settled, asked, skipped, problems, missing}
// const events = R.merge(form, built, reply).filter((e) => e.status === "ok" && K.exportable(e));
// K.ics(events, { tz: form.tz, name: K.title(form) })   // the .ics text, times in UTC
// K.gcalCSV(events)                      // Google Calendar's import CSV

Paste keys: rows (the rows as copied from the sheet, or plain lines), tz (an IANA time zone; one the engine does not know falls back to the system's), today (YYYY-MM-DD; empty takes today in tz), order (auto, MDY or DMY) and dur (the default length in minutes, or such as 1h 30m). Here two dates can only be month-first, so the order is settled; the Potluck row says "next Friday", so it is the one row sent, and counted from Monday 2026-11-02, next Friday is 2026-11-06 - the date the page's check expects back. The engine sends nothing anywhere: it only reads the text you give it.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. 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://on-the-calendar.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":"on-the-calendar"}' | 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": "tidy", "today": "2026-11-02 (Monday)", "timezone": "America/New_York", "sheet": "Format: tab-separated; columns: Title | Date | Time | Place\nDate order: month first (2 dates in the sheet can only be month-first)\nDefault length: 60 minutes\nRows the page read cleanly (2 in all), for the sheet's habits:\nr1. Title: Book club | Date: 11/19 | Time: 7pm | Place: Maple Library -> 2026-11-19 19:00-20:00\nr3. Title: Board meeting | Date: 11/24/2026 | Time: 18:00 | Place: Room 4 -> 2026-11-24 18:00-19:00", "rows": "r2. Title: Potluck | Date: next Friday | Time: 6:30pm | Place: Community hall\n   the page read 18:30-19:30; unclear: \"next Friday\" names a day relative to another, not a date.", "facts": "3 rows read, 2 clear, 1 unclear row."}
# - see the input 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 on-the-calendar:<task>:<inputHash>:a<attempt> (the one task is tidy), 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="on-the-calendar:tidy:$(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, .note' reply.json
jq -r '.events[]? | "\(.row) \(.date) \(.start) \(.title)"' reply.json   # settled rows; start is "" when all day
jq -r '.questions[]? | "\(.row) \(.question)"' reply.json               # rows only you can settle

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: on-the-calendar:tidy:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json