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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required key (task, today, timezone, sheet, rows, facts) 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 tidy 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: 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):
| Key | Required | What it holds |
|---|---|---|
task | yes | Always tidy. If it is missing or different, still answer this task and set "task": "tidy". |
today | yes | Today's date and weekday, as YYYY-MM-DD (Weekday). First sample: 2026-11-02 (Monday). |
timezone | yes | The time zone the times are in. First sample: America/Chicago. |
sheet | yes | 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, each with what it read. First sample: Format: tab-separated; columns: Event | Date | Time | Location | Notes ... |
rows | yes | The 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 ... |
facts | yes | The counts and anything the page noticed about the whole sheet. First sample: 7 rows read, 5 clear, 2 unclear rows. |
retry_note | no | Only 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.
| Kind | Header names | What the page takes from it |
|---|---|---|
title | title, summary, event, event name, name, what, subject, activity, session, class, item, description of event | the event's title |
date | date, day, when, event date, start date, on | the date (a date and time together are split) |
enddate | end date, to date, until date, finish date | the last day of a multi-day all-day event |
start | start, start time, time, from, begins, starts, start at, kick off | the start time, or a range such as 6:30-7:30pm |
end | end, end time, to, until, finish, finishes, ends | the end time |
duration | duration, length, mins, minutes, hours, hrs, how long | the length, such as 90, 1:30 or 1h 30m |
location | location, where, venue, place, address, room, meeting point | the place |
description | description, notes, details, info, agenda, comments, what to bring | notes, kept as the event's description |
attendees | attendees, guests, emails, email, invite, invitees, people, who | guests: every email address in the cell |
allday | all day, all-day, allday, all day event | all 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.
| Code | Level | When |
|---|---|---|
ambiguous_date | check | A numeric date whose day and month could be read either way, when the sheet itself does not settle the order |
weekday_mismatch | fix | The row names a weekday that is not the weekday of its date |
relative_date | fix | A day named relative to another (next or this weekday, next week, the weekend, the 3rd) rather than a date |
no_date | fix | A date cell (or line) the page cannot read as a date |
missing_date | fix | No date at all |
time_tbd | fix | The time is TBD, TBC, TBA or ? |
early_hour | check | A time from 1 to 5 written without am or pm (read as written, early in the morning) |
no_ampm | check | A bare hour such as 9 in a time column, with no am or pm |
vague_time | fix | A time the row only describes - morning, after work, lunch, 3ish |
no_time | fix | A time cell the page cannot read as a time |
no_end | check | An end cell that is not a time (the default length is used) |
end_before_start | fix | An end before the start (an evening start with an end before 6am is read as ending the next day instead) |
no_emails | check | A guests cell with no email address in it, so no invitation goes out |
no_title | fix | No title |
place_tbd | check | The 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:
- Answer every row in
rowsexactly once: as an entry inevents(settled), inquestions(only the person can settle it) or inskip(the row is not an event - a heading, a note, a blank). 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,
todayand 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 aftertoday; "tomorrow" is the day aftertoday. - 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_dayis true exactly whenstartis "".endmay be "" (the page then uses the default length); when given, it is afterstart.titleandlocationuse 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.
- Settle a row only from its own words,
questions- each{ "row": its id, "question": one plain question the person can answer in a word or a time, at most 25 words }.skip- each{ "row": its id, "why": one sentence }.verdict-askwhenquestionshas any entry; otherwiseready.note- one sentence, at most 30 words: how many rows are settled and what to answer next.
And the house rules:
- Use only the input. Never invent a date, a time, a place, a person or a title word.
- Any number in
why, a question ornotemust appear in the input. - Never use backticks or the input's field names in a sentence, and never copy this prompt's own wording into a reply.
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):
| Row | Date | Time | Title | Status | Issues |
|---|---|---|---|---|---|
| r1 | 2026-11-07 Sat | 07:00-08:00 | Long run | ok | - |
| r2 | 2026-11-10 Tue | 18:30-19:30 | Track night | ok | - |
| r3 | 2026-11-14 Sat | - | Social | unread | vague_time (fix): "after the run" is not a clock time.; place_tbd (check): The place is to be decided. |
| r4 | 2026-11-17 Tue | 18:30-19:30 | Hill repeats | ok | - |
| r5 | - | 08:00-09:00 | Trail day | unread | relative_date (fix): "next Saturday" names a day relative to another, not a date. |
| r6 | 2026-11-21 Sat | 07:00-08:00 | Long run | ok | - |
| r7 | 2026-11-26 Thu | 08:30-09:30 | Turkey trot | ok | - |
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):
- r5 settled: 2026-11-07 Sat, 08:00-09:00 - Trail day at Wolf Creek trailhead. Why: Next Saturday means the first Saturday after November 2, which is November 7.
- r3 asked: What time does the November 14 social begin, since "after the run" isn't a clock reading?
The calendar after the merge (ReplyKit.merge); 6 of 7 rows go into the .ics file and the CSV:
| Row | Date | Time | Title | Place | From |
|---|---|---|---|---|---|
| r1 | 2026-11-07 Sat | 07:00-08:00 | Long run | Riverside Park | read by the page; in the files |
| r2 | 2026-11-10 Tue | 18:30-19:30 | Track night | North High track | read by the page; in the files |
| r3 | 2026-11-14 Sat | - | Social | - | question: What time does the November 14 social begin, since "after the run" isn't a clock reading? |
| r4 | 2026-11-17 Tue | 18:30-19:30 | Hill repeats | Grant Hill | read by the page; in the files |
| r5 | 2026-11-07 Sat | 08:00-09:00 | Trail day | Wolf Creek trailhead | settled by the run; in the files |
| r6 | 2026-11-21 Sat | 07:00-08:00 | Long run | Riverside Park | read by the page; in the files |
| r7 | 2026-11-26 Thu | 08:30-09:30 | Turkey trot | City Hall | read 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):
| Row | Date | Time | Title | Status | Issues |
|---|---|---|---|---|---|
| r1 | 2026-11-03 Tue | 16:30-17:30 | dentist for Mia | ok | - |
| r2 | 2026-11-06 Fri | 18:00-19:30 | school play | ok | - |
| r3 | - | 09:00-10:00 | swimming at the leisure centre | unread | relative_date (fix): "this Saturday swimming 9am at the..." names a day relative to another, not a date. |
| r4 | 2026-11-08 Sun | 13:00-14:00 | roast at Gran's | ok | - |
| r5 | 2026-11-14 Sat | 14:00-16:00 | Leo's party | ok | - |
| r6 | 2026-11-17 Tue | 17:15-18:15 | parents' evening | ok | - |
| r7 | 2026-11-28 Sat | all day | Christmas market all day | ok | - |
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):
- r3 settled: 2026-11-07 Sat, 09:00-10:00 - swimming at 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.
The calendar after the merge (ReplyKit.merge); 7 of 7 rows go into the .ics file and the CSV:
| Row | Date | Time | Title | Place | From |
|---|---|---|---|---|---|
| r1 | 2026-11-03 Tue | 16:30-17:30 | dentist for Mia | - | read by the page; in the files |
| r2 | 2026-11-06 Fri | 18:00-19:30 | school play | - | read by the page; in the files |
| r3 | 2026-11-07 Sat | 09:00-10:00 | swimming | the leisure centre | settled by the run; in the files |
| r4 | 2026-11-08 Sun | 13:00-14:00 | roast at Gran's | - | read by the page; in the files |
| r5 | 2026-11-14 Sat | 14:00-16:00 | Leo's party | - | read by the page; in the files |
| r6 | 2026-11-17 Tue | 17:15-18:15 | parents' evening | - | read by the page; in the files |
| r7 | 2026-11-28 Sat | all day | Christmas 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):
| Row | Date | Time | Title | Status | Issues |
|---|---|---|---|---|---|
| r1 | 2026-11-18 Wed | 09:00-10:00 | Opening keynote | ok | - |
| r2 | 2026-11-18 Wed | 10:30-12:00 | Workshop: Testing data pipelines | ok | - |
| r3 | 2026-11-18 Wed | 12:00-13:00 | Lunch | ok | - |
| r4 | 2026-11-18 Wed | 14:00-15:00 | Panel: Open data in cities | ok | - |
| r5 | 2026-11-19 Thu | 19:00-19:45 | Speakers' dinner | unread | weekday_mismatch (fix): "Wed 19 Nov" says Wed, but 2026-11-19 is a Thu. |
| r6 | 2026-11-19 Thu | 09:00-10:00 | Day 2 keynote | check | no_ampm (check): "9" has no am or pm; read as 09:00. |
| r7 | 2026-11-19 Thu | 16:00-15:00 | Closing remarks | unread | end_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):
- r6 settled: 2026-11-19 Thu, 09:00-10:00 - Day 2 keynote at Hall A. Why: Hour 9 to 10 lacked am or pm; kept as 09:00 to 10:00 like the sheet's morning sessions.
- r5 asked: Is the speakers' dinner on Wednesday 18 November or Thursday 19 November, since 19 Nov 2026 is a Thursday not a Wednesday?
- r7 asked: Closing remarks starts at 16:00; what time does it end, since 15:00 is before the start?
The calendar after the merge (ReplyKit.merge); 5 of 7 rows go into the .ics file and the CSV:
| Row | Date | Time | Title | Place | From |
|---|---|---|---|---|---|
| r1 | 2026-11-18 Wed | 09:00-10:00 | Opening keynote | Hall A | read by the page; in the files |
| r2 | 2026-11-18 Wed | 10:30-12:00 | Workshop: Testing data pipelines | Room 2 | read by the page; in the files |
| r3 | 2026-11-18 Wed | 12:00-13:00 | Lunch | Foyer | read by the page; in the files |
| r4 | 2026-11-18 Wed | 14:00-15:00 | Panel: Open data in cities | Hall B | read by the page; in the files |
| r5 | 2026-11-19 Thu | 19:00-19:45 | Speakers' dinner | Brauhaus am Markt | question: Is the speakers' dinner on Wednesday 18 November or Thursday 19 November, since 19 Nov 2026 is a Thursday not a Wednesday? |
| r6 | 2026-11-19 Thu | 09:00-10:00 | Day 2 keynote | Hall A | settled by the run; in the files |
| r7 | 2026-11-19 Thu | 16:00-15:00 | Closing remarks | Hall A | question: 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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://on-the-calendar.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"]
// on-the-calendar.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://on-the-calendar.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://on-the-calendar.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
}
// OnTheCalendar.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 OnTheCalendar {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://on-the-calendar.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://on-the-calendar.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://on-the-calendar.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://on-the-calendar.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":"on-the-calendar"}' | 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": "on-the-calendar"})["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: "on-the-calendar" })).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": "on-the-calendar"}, 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", "on-the-calendar"), 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: "on-the-calendar" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "on-the-calendar"])["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 = "on-the-calendar" }))!["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": "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}'
inp = json.load(open("input.json")) # {"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
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: "tidy", today, timezone, sheet, rows, facts} (see the input 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 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
import hashlib, time
key = "on-the-calendar:" + 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"))
print(reply.get("note", ""))
for e in reply.get("events") or []: # settled rows; start is "" when all day
print(e["row"], e["date"], e["start"], e["title"])
for q in reply.get("questions") or []: # rows only you can settle
print(q["row"], q["question"])
import { createHash } from "node:crypto";
const key = `on-the-calendar:${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);
console.log(reply.note);
for (const e of reply.events || []) console.log(e.row, e.date, e.start, e.title); // start is "" when all day
for (const q of reply.questions || []) console.log(q.row, q.question); // rows only you can settle
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "on-the-calendar:" + 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)
fmt.Println(j["charged_credits"], j["truncated"], reply["verdict"])
fmt.Println(reply["note"])
events, _ := reply["events"].([]any) // settled rows; start is "" when all day
for _, x := range events {
e := x.(map[string]any)
fmt.Println(e["row"], e["date"], e["start"], e["title"])
}
questions, _ := reply["questions"].([]any) // rows only you can settle
for _, x := range questions {
q := x.(map[string]any)
fmt.Println(q["row"], q["question"])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "on-the-calendar:" + 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"));
System.out.println(reply.path("note").asText());
for (JsonNode e : reply.path("events")) // settled rows; start is "" when all day
System.out.println(e.path("row").asText() + " " + e.path("date").asText() + " " + e.path("start").asText() + " " + e.path("title").asText());
for (JsonNode q : reply.path("questions")) System.out.println(q.path("row").asText() + " " + q.path("question").asText());
require "digest"
key = "on-the-calendar:#{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
puts reply["note"]
(reply["events"] || []).each { |e| puts "#{e["row"]} #{e["date"]} #{e["start"]} #{e["title"]}" } # start is "" when all day
(reply["questions"] || []).each { |q| puts "#{q["row"]} #{q["question"]}" }
$key = "on-the-calendar:" . $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";
echo $reply["note"] ?? "", "\n";
foreach ($reply["events"] ?? [] as $e) echo $e["row"], " ", $e["date"], " ", $e["start"], " ", $e["title"], "\n"; // start is "" when all day
foreach ($reply["questions"] ?? [] as $q) echo $q["row"], " ", $q["question"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"on-the-calendar:{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"]}");
Console.WriteLine($"{reply["note"]}");
foreach (var e in reply["events"] as JsonArray ?? new JsonArray()) Console.WriteLine($"{e!["row"]} {e["date"]} {e["start"]} {e["title"]}"); // start is "" when all day
foreach (var q in reply["questions"] as JsonArray ?? new JsonArray()) Console.WriteLine($"{q!["row"]} {q["question"]}");
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "on-the-calendar:tidy:" + 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": `on-the-calendar:tidy:${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", "on-the-calendar:tidy:"+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", "on-the-calendar:tidy:" + 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" => "on-the-calendar:tidy:#{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: on-the-calendar:tidy:" . 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", $"on-the-calendar:tidy:{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);