← Who Came
API

Run Who Came from your own code

The one metered task, attendance, is available over HTTP. It takes a meeting's attendance as the page has read it - what the meeting was for and who it needed, the scheduled slot with the counts and the most people at once, one numbered line per person (p1, p2 ...) with when they joined and left, the minutes present and the share of the slot, drop-outs, time away and minutes missed, and the ids of the people the page flagged - and returns one follow-up per flagged person (recap, check_in, none) with a reason for each, a verdict (covered or gaps), a draft recap message and a note.

The division of labour first: the page reads the report, merges each person's rejoins, clips them to the scheduled slot, matches them to the invite list and flags who missed part of the meeting; the run decides each flagged person's follow-up, naming people only by id so the page can tie each follow-up to a real row of the report. The page reads up to 5000 lines and keeps up to 60 people from the report, plus every invitee who never joined. After the reply it keeps one known follow-up per flagged person, checks each one against its own rule and the verdict against its own count, and warns when a sentence quotes a number the report does not have, guesses why someone was late or away, or calls anyone he or she. Nothing is sent from the page; the recap message is a draft to copy. Your code can do the same with the engine files the page loads (see "Build the input yourself").

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":"who-came"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, goal, meeting, people, flagged) 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 attendance 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: attendance (the one task is attendance); a missing or different one is still answered as attendance. All fields are strings. What each holds, in the words of the system prompt, with the first sample's value (its first line where it has several):

FieldRequiredWhat it holds
taskyesAlways attendance. If it is missing or different, still answer this task and set "task": "attendance".
goalyesWhat the meeting was for and who it needed, in the organiser's words, or none. First sample: Checkout redesign review - Lena has to sign off on the final layout.
meetingyesThe scheduled slot and its length, how many were invited and joined, the most people at once, and who was marked needed. First sample: scheduled 14:00-15:00, 60 min; 6 invited, 5 of them joined, 1 did not; most at once: 5 at 14:12; 2 there in the last minute; needed: Lena Fennwick.
peopleyesOne line per person: p1. Name (needed) | joined HH:MM, N min late | left HH:MM, N min early | present N of L min (S%) | dropped out twice: HH:MM-HH:MM, ... | stepped away N min | missed N min | not on the invite list, or p7. Name | invited, did not join | missed L min. Parts that do not apply are left out. First sample: p1. Omar Hadderly | joined 13:58 | left 15:00 | present 60 of 60 min (100%) ...
flaggedyesThe ids of the people the page flagged, or none. First sample: p2, p4, p5, p6.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

How the page reads the report (RollKit.readReport, called by RollKit.form): up to 5000 lines. A table (tab, comma or semicolon separated) is found by its header row: a name column (name, full name, participant, display name, attendee, user name, name (original name)) or a first name and a last name column; a join column (a header holding join, first seen, arrived, entered or start time); and a leave column (leave, left, exit, last seen, end time, departed) or a duration column (duration, time in call, time in meeting, attendance time, minutes); an email column is used when there is one. That covers a Google Meet attendance sheet (first join, last exit, total duration), a Zoom participants export (one row per session) and a Microsoft Teams attendance report; with several tables, as in a Teams report, the one with a leave column and the most rows is used. Times can be 24-hour or AM/PM, with or without seconds and dates (a later date moves the time on by whole days); a duration reads as 1 hr 5 min, 1h 5m 3s, 37 min, 01:05:03 or bare minutes. When a row has both a leave time and a duration and the duration is a minute or more shorter, the difference is time away the report does not place. Without a header, each line with a name followed by pairs of times is read, as in Name 10:02-10:41, 10:45-11:00. A table row with no name or no readable times is set aside and counted in the warning.

What the page works out, free and before any run: each person's stretches in the call, joined by email, else by name (case and anything in brackets ignored), with gaps under a minute merged and a gap of a minute or more inside the slot counted as a drop-out (RollKit.people). The invite list (RollKit.readInvites) is one person per line, or separated by commas or semicolons, a name and/or an email, with a * before or after anyone the meeting needed; it is matched by email first, then by name, then by a name against an email such as first.last@. Invitees who never joined get a row of their own; people who joined without an invite are marked as not on the invite list. The slot is read as a start and an end (14:00-15:00, 2pm-3:30pm, 10:00 to 11:30); when none is given, it runs from the first join to the last leave, rounded out to five minutes, and the input says so. Everyone's time is clipped to the slot; time present is that, less any time away. The page flags (RollKit.form) anyone who joined 5 minutes or more late, left 5 minutes or more early, dropped out at all, was away 5 minutes or more or was present for under half the slot, and every invitee who did not join. It also counts how many were there in the middle of each minute, for the headcount curve, the most at once and the last minute.

The model's rules

As the system prompt states them, in order:

  1. follow_up - one entry for each id in flagged, in id order: person (the id), action, and why (one sentence, at most 20 words, saying what the person missed with the times and minutes from their line). Take the first action that applies:
    • check_in - the person is marked needed and either did not join or was present for under half the meeting (S under 50%), or the person dropped out twice or more. The organiser asks them directly how it went.
    • recap - the person missed 10 minutes or more. The organiser sends them the notes for the part they missed.
    • none - anyone else (they missed under 10 minutes).
  2. verdict - covered when every person marked needed was present for at least half the meeting AND at least half of the invited people were present for at least half (when there is no invite list, half of those who joined; people not on the invite list never count). gaps otherwise.
  3. message - the recap message for the people whose action is recap, at most 70 words: greet each of them by first name, say which part of the meeting each missed using the times from their line, and say the notes will follow. For what the meeting was about, use only goal. "" when nobody's action is recap.
  4. note - one sentence, at most 30 words, for the organiser: whether the meeting had the people it needed, and the first follow-up to make - a check-in comes before any recap.

And the house rules:

The reply

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

{
  "task": "attendance",
  "verdict": "covered | gaps",
  "follow_up": [ { "person": "p2", "action": "check_in | recap | none", "why": "one sentence" } ],
  "message": "the recap message, or empty",
  "note": "one sentence"
}

The page reads the reply with ReplyKit.normalize (the verdict, the ids and the actions matched in any case, and an action written as two words or with a hyphen, such as check in, read as its id) and keeps what it trusts with ReplyKit.trusted(reply, built): an entry is set aside, with the reason, when it names someone the page did not send, someone the page did not flag, someone who already has a follow-up, or an action the page does not know; every flagged person with no follow-up is listed as missing. t.acts maps each id to its follow-up and t.why to its reason. The names, the times and the minutes always come from the page's own reading, never from the reply.

ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one that the page's own count contradicts (RollKit.expectVerdict: every needed person present for at least half the slot, and at least half of the invited - or, with no invite list, of those who joined - present for at least half; people not on the invite list never count); every entry set aside, and every flagged person with no follow-up; a follow-up that is not the one the rule gives (RollKit.expectAction: check in with a needed person who did not join or was present for under half, or anyone who dropped out twice or more; send the recap to anyone else who missed 10 minutes or more; no follow-up otherwise); no recap message though someone is down for one, a message when nobody is, a message that does not greet someone down for the recap or names someone who is not; a number in a sentence that is not in the input; a sentence that names a person's id or an internal field or uses backticks; a sentence that guesses why (illness, traffic, the connection, another meeting ...) when the input does not say; a sentence that calls anyone he, she, him or her; and words such as "again", "already", "always", "never" or "every" that the input never used. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "The people it needed were there" or "It was missing people it needed".

The exports: RollKit.toCSV(form, t.acts) writes one row per person in id order - the name, the email, whether they were invited and needed, when they joined and left, the minutes present and the share, the minutes late and early, the drop-outs, the minutes away, what happened and the follow-up - with CRLF line ends; ReplyKit.md and ReplyKit.json write the slot, the counts and every person with what happened, the follow-up and its reason, and the recap message; and the register (RollRegister.draw) saves as PNG or SVG.

Worked examples - every sample

These are the free samples on the page, one per report shape: the form as filled in, how the page reads the report - the people, the slot, the headcount and who it flags - the exact input it sends, the reply that replays for free, and what the page does with it - the follow-ups it trusts, person by person against its own rule, the verdict against its own count, the recap message and its own check. Together they cover the verdicts covered and gaps.

A design review (Google Meet)

The form: the meeting is Checkout redesign review - Lena has to sign off on the final layout; the slot: 14:00-15:00. Who was invited (a * marks someone the meeting needed):

* Lena Fennwick
Omar Hadderly
Priya Nallory
Jonas Brannick
Mei Chellow
Sam Okaverre <sam.okaverre@northwind.example>

The report as pasted:

First name,Last name,Email,Duration,Time joined,Time exited
Omar,Hadderly,omar.hadderly@northwind.example,1 hr 1 min,13:58:12,15:00:05
Jonas,Brannick,jonas.brannick@northwind.example,30 min,14:00:40,14:31:20
Priya,Nallory,priya.nallory@northwind.example,58 min,14:01:30,14:59:50
Mei,Chellow,mei.chellow@northwind.example,38 min,14:03:15,14:57:00
Lena,Fennwick,lena.fennwick@northwind.example,41 min,14:12:05,14:58:30

The page reads 6 lines as a Google Meet attendance sheet (first join, last exit, total duration): 5 sessions for 5 people who joined. The slot is 14:00-15:00, 60 min. Of 6 invitees, 5 joined and 1 did not. The most at once: 5 at 14:12; 2 there in the last minute.

The people (RollKit.form), in id order, each sent as one line (RollKit.personLine):

IdPersonJoinedLeftPresentWhat happenedFlagged for
p1Omar Hadderly13:5815:0060 min (100%)there throughout-
p2Jonas Brannick14:0114:3131 min (51%)left 29 min earlyearly
p3Priya Nallory14:0215:0058 min (97%)there throughout-
p4Mei Chellow14:0314:5738 min (63%)away 16 minaway
p5Lena Fennwick (needed)14:1214:5941 min (68%)12 min late, away 5 minlate, away
p6Sam Okaverre--0 min (0%)did not joinabsent

Input:

{
 "task": "attendance",
 "goal": "Checkout redesign review - Lena has to sign off on the final layout",
 "meeting": "scheduled 14:00-15:00, 60 min; 6 invited, 5 of them joined, 1 did not; most at once: 5 at 14:12; 2 there in the last minute; needed: Lena Fennwick",
 "people": "p1. Omar Hadderly | joined 13:58 | left 15:00 | present 60 of 60 min (100%)\np2. Jonas Brannick | joined 14:01 | left 14:31, 29 min early | present 31 of 60 min (51%) | missed 29 min\np3. Priya Nallory | joined 14:02 | left 15:00 | present 58 of 60 min (97%) | missed 2 min\np4. Mei Chellow | joined 14:03 | left 14:57 | present 38 of 60 min (63%) | stepped away 16 min (when is not in the report) | missed 22 min\np5. Lena Fennwick (needed) | joined 14:12, 12 min late | left 14:59 | present 41 of 60 min (68%) | stepped away 5 min (when is not in the report) | missed 19 min\np6. Sam Okaverre | invited, did not join | missed 60 min",
 "flagged": "p2, p4, p5, p6"
}

Reply:

{
 "task": "attendance",
 "verdict": "covered",
 "follow_up": [
  {
   "person": "p2",
   "action": "recap",
   "why": "Jonas Brannick left at 14:31, 29 minutes early, missing 29 minutes of the meeting."
  },
  {
   "person": "p4",
   "action": "recap",
   "why": "Mei Chellow left at 14:57 and missed 22 minutes, including 16 minutes stepped away."
  },
  {
   "person": "p5",
   "action": "recap",
   "why": "Lena Fennwick joined 12 minutes late at 14:12 and missed 19 minutes, including 5 minutes stepped away."
  },
  {
   "person": "p6",
   "action": "recap",
   "why": "Sam Okaverre did not join and missed the full 60 minutes of the meeting."
  }
 ],
 "message": "Hi Jonas, Mei, Lena, and Sam. This covers the Checkout redesign review. Jonas left at 14:31, missing the last 29 minutes. Mei left at 14:57, missing 22 minutes, including 16 minutes stepped away. Lena joined 12 minutes late at 14:12, missed 19 minutes including 5 minutes stepped away, and left at 14:59. Sam did not join and missed the full 60 minutes. Notes on what each missed will follow.",
 "note": "The meeting had the person it needed, so the first step is sending catch-up notes to Jonas, Mei, Lena, and Sam for the time each missed."
}

The follow-ups the page trusts (ReplyKit.trusted), flagged person by person, next to the one its own rule gives (RollKit.expectAction):

PersonWhat happenedMissedFollow-upThe ruleWhy
Jonas Brannick (p2)left 29 min early29 minrecap (send the recap)recap (send the recap)Jonas Brannick left at 14:31, 29 minutes early, missing 29 minutes of the meeting.
Mei Chellow (p4)away 16 min22 minrecap (send the recap)recap (send the recap)Mei Chellow left at 14:57 and missed 22 minutes, including 16 minutes stepped away.
Lena Fennwick (p5)12 min late, away 5 min19 minrecap (send the recap)recap (send the recap)Lena Fennwick joined 12 minutes late at 14:12 and missed 19 minutes, including 5 minutes stepped away.
Sam Okaverre (p6)did not join60 minrecap (send the recap)recap (send the recap)Sam Okaverre did not join and missed the full 60 minutes of the meeting.

The verdict: covered - "The people it needed were there"; it agrees with the page's own count.

The recap message: "Hi Jonas, Mei, Lena, and Sam. This covers the Checkout redesign review. Jonas left at 14:31, missing the last 29 minutes. Mei left at 14:57, missing 22 minutes, including 16 minutes stepped away. Lena joined 12 minutes late at 14:12, missed 19 minutes including 5 minutes stepped away, and left at 14:59. Sam did not join and missed the full 60 minutes. Notes on what each missed will follow."

The note: "The meeting had the person it needed, so the first step is sending catch-up notes to Jonas, Mei, Lena, and Sam for the time each missed."

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

No notes.

A client kickoff (Zoom)

The form: the meeting is Website project kickoff with the client - Dana approves the scope and the launch date; the slot: 9:30-10:30 AM. Who was invited (a * marks someone the meeting needed):

* Dana Whitlarn <dana@quellmardental.example>
* Raj Pellavan
Elena Rossavel
Kwame Mensoro
Grace Lindqvar
Tom Bexmoor

The report as pasted:

Meeting ID,Topic,Start Time,End Time,User Email,Duration (Minutes),Participants
812 4471 9032,Kickoff - Quellmar Dental website,10/27/2026 09:27:55 AM,10/27/2026 10:31:02 AM,raj.pellavan@brindlecove.example,64,7

Name (Original Name),User Email,Join Time,Leave Time,Duration (Minutes),Guest
Raj Pellavan,raj.pellavan@brindlecove.example,10/27/2026 09:27:55 AM,10/27/2026 10:31:02 AM,64,No
Elena Rossavel,elena.rossavel@brindlecove.example,10/27/2026 09:29:10 AM,10/27/2026 10:30:40 AM,62,No
Kwame Mensoro,kwame.mensoro@brindlecove.example,10/27/2026 09:31:00 AM,10/27/2026 10:12:40 AM,42,No
Dana Whitlarn,dana@quellmardental.example,10/27/2026 09:34:20 AM,10/27/2026 09:51:05 AM,17,Yes
Grace L (Grace Lindqvar),grace.lindqvar@brindlecove.example,10/27/2026 09:36:45 AM,10/27/2026 10:29:30 AM,53,No
Dana Whitlarn,dana@quellmardental.example,10/27/2026 09:53:40 AM,10/27/2026 10:02:15 AM,9,Yes
Marcus Halverin,marcus@quellmardental.example,10/27/2026 09:45:00 AM,10/27/2026 10:30:30 AM,46,Yes
Dana Whitlarn,dana@quellmardental.example,10/27/2026 10:06:30 AM,10/27/2026 10:30:12 AM,24,Yes

The page reads 12 lines as a Zoom participants export (one row per session): 8 sessions for 6 people who joined. The slot is 09:30-10:30, 60 min. Of 6 invitees, 5 joined and 1 did not, plus 1 person not on the invite list. The most at once: 6 at 09:45; 4 there in the last minute.

The people (RollKit.form), in id order, each sent as one line (RollKit.personLine):

IdPersonJoinedLeftPresentWhat happenedFlagged for
p1Raj Pellavan (needed)09:2810:3160 min (100%)there throughout-
p2Elena Rossavel09:2910:3160 min (100%)there throughout-
p3Kwame Mensoro09:3110:1342 min (69%)left 17 min earlyearly
p4Dana Whitlarn (needed)09:3410:3049 min (81%)out twice (09:51-09:54, 10:02-10:07)dropped
p5Grace Lindqvar09:3710:3053 min (88%)7 min latelate
p6Marcus Halverin (not on the invite list)09:4510:3145 min (75%)15 min latelate
p7Tom Bexmoor--0 min (0%)did not joinabsent

Input:

{
 "task": "attendance",
 "goal": "Website project kickoff with the client - Dana approves the scope and the launch date",
 "meeting": "scheduled 09:30-10:30, 60 min; 6 invited, 5 of them joined, 1 did not, plus 1 not on the invite list; most at once: 6 at 09:45; 4 there in the last minute; needed: Raj Pellavan, Dana Whitlarn",
 "people": "p1. Raj Pellavan (needed) | joined 09:28 | left 10:31 | present 60 of 60 min (100%)\np2. Elena Rossavel | joined 09:29 | left 10:31 | present 60 of 60 min (100%)\np3. Kwame Mensoro | joined 09:31 | left 10:13, 17 min early | present 42 of 60 min (69%) | missed 18 min\np4. Dana Whitlarn (needed) | joined 09:34 | left 10:30 | present 49 of 60 min (81%) | dropped out twice: 09:51-09:54, 10:02-10:07 | missed 11 min\np5. Grace Lindqvar | joined 09:37, 7 min late | left 10:30 | present 53 of 60 min (88%) | missed 7 min\np6. Marcus Halverin | joined 09:45, 15 min late | left 10:31 | present 45 of 60 min (75%) | missed 15 min | not on the invite list\np7. Tom Bexmoor | invited, did not join | missed 60 min",
 "flagged": "p3, p4, p5, p6, p7"
}

Reply:

{
 "task": "attendance",
 "verdict": "covered",
 "follow_up": [
  {
   "person": "p3",
   "action": "recap",
   "why": "Kwame Mensoro left at 10:13, 17 minutes early, missing 18 minutes of the meeting."
  },
  {
   "person": "p4",
   "action": "check_in",
   "why": "Dana Whitlarn dropped out twice, from 09:51 to 09:54 and 10:02 to 10:07, missing 11 minutes."
  },
  {
   "person": "p5",
   "action": "none",
   "why": "Grace Lindqvar joined 7 minutes late at 09:37 and missed 7 minutes of the meeting."
  },
  {
   "person": "p6",
   "action": "recap",
   "why": "Marcus Halverin joined 15 minutes late at 09:45 and missed 15 minutes of the meeting."
  },
  {
   "person": "p7",
   "action": "recap",
   "why": "Tom Bexmoor did not join and missed the full 60 minutes of the meeting."
  }
 ],
 "message": "Hi Kwame, Marcus, and Tom. This covers the website project kickoff with the client. Kwame left at 10:13, 17 minutes early, missing 18 minutes. Marcus joined 15 minutes late at 09:45, missing 15 minutes. Tom did not join and missed the full 60 minutes. Notes on what each missed will follow.",
 "note": "The meeting had both people it needed, but Dana Whitlarn dropped out twice, so the first step is a direct conversation with Dana about it."
}

The follow-ups the page trusts (ReplyKit.trusted), flagged person by person, next to the one its own rule gives (RollKit.expectAction):

PersonWhat happenedMissedFollow-upThe ruleWhy
Kwame Mensoro (p3)left 17 min early18 minrecap (send the recap)recap (send the recap)Kwame Mensoro left at 10:13, 17 minutes early, missing 18 minutes of the meeting.
Dana Whitlarn (p4)out twice11 mincheck_in (check in)check_in (check in)Dana Whitlarn dropped out twice, from 09:51 to 09:54 and 10:02 to 10:07, missing 11 minutes.
Grace Lindqvar (p5)7 min late7 minnone (no follow-up)none (no follow-up)Grace Lindqvar joined 7 minutes late at 09:37 and missed 7 minutes of the meeting.
Marcus Halverin (p6)15 min late15 minrecap (send the recap)recap (send the recap)Marcus Halverin joined 15 minutes late at 09:45 and missed 15 minutes of the meeting.
Tom Bexmoor (p7)did not join60 minrecap (send the recap)recap (send the recap)Tom Bexmoor did not join and missed the full 60 minutes of the meeting.

The verdict: covered - "The people it needed were there"; it agrees with the page's own count.

The recap message: "Hi Kwame, Marcus, and Tom. This covers the website project kickoff with the client. Kwame left at 10:13, 17 minutes early, missing 18 minutes. Marcus joined 15 minutes late at 09:45, missing 15 minutes. Tom did not join and missed the full 60 minutes. Notes on what each missed will follow."

The note: "The meeting had both people it needed, but Dana Whitlarn dropped out twice, so the first step is a direct conversation with Dana about it."

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

No notes.

A team training (Teams)

The form: the meeting is Training on the new quarter-close checklist - Ines and Victor, the two controllers, must be able to run it on their own next week; the slot: 13:00-14:30. Who was invited (a * marks someone the meeting needed):

Hana Sarvelle
* Ines Morvaine
* Victor Adeloran
Leo Marrowby
Ruth Kelvaro
Paul Grennick
Nora Quorvel

The report as pasted (the gaps in its lines are tabs):

1. Summary
Meeting title	Quarter-close checklist training
Attended participants	6
Start time	10/22/26, 12:58:14 PM
End time	10/22/26, 2:31:40 PM
Meeting duration	1h 33m 26s

2. Participants
Name	First Join	Last Leave	In-Meeting Duration	Email	Participant ID (UPN)	Role
Hana Sarvelle	10/22/26, 12:58:14 PM	10/22/26, 2:31:40 PM	1h 33m 26s	hana.sarvelle@contoso.example	hana.sarvelle@contoso.example	Organizer
Victor Adeloran	10/22/26, 1:00:05 PM	10/22/26, 2:30:05 PM	1h 28m 20s	victor.adeloran@contoso.example	victor.adeloran@contoso.example	Attendee
Leo Marrowby	10/22/26, 1:00:20 PM	10/22/26, 2:30:00 PM	1h 29m 40s	leo.marrowby@contoso.example	leo.marrowby@contoso.example	Presenter
Ruth Kelvaro	10/22/26, 1:01:00 PM	10/22/26, 2:00:20 PM	59m 20s	ruth.kelvaro@contoso.example	ruth.kelvaro@contoso.example	Attendee
Ines Morvaine	10/22/26, 1:02:30 PM	10/22/26, 1:41:10 PM	38m 40s	ines.morvaine@contoso.example	ines.morvaine@contoso.example	Attendee
Paul Grennick	10/22/26, 1:14:40 PM	10/22/26, 2:29:55 PM	1h 15m 15s	paul.grennick@contoso.example	paul.grennick@contoso.example	Attendee

3. In-Meeting Activities
Name	Join Time	Leave Time	Duration	Email	Role
Hana Sarvelle	10/22/26, 12:58:14 PM	10/22/26, 2:31:40 PM	1h 33m 26s	hana.sarvelle@contoso.example	Organizer
Victor Adeloran	10/22/26, 1:00:05 PM	10/22/26, 1:47:20 PM	47m 15s	victor.adeloran@contoso.example	Attendee
Leo Marrowby	10/22/26, 1:00:20 PM	10/22/26, 2:30:00 PM	1h 29m 40s	leo.marrowby@contoso.example	Presenter
Ruth Kelvaro	10/22/26, 1:01:00 PM	10/22/26, 2:00:20 PM	59m 20s	ruth.kelvaro@contoso.example	Attendee
Ines Morvaine	10/22/26, 1:02:30 PM	10/22/26, 1:41:10 PM	38m 40s	ines.morvaine@contoso.example	Attendee
Victor Adeloran	10/22/26, 1:49:00 PM	10/22/26, 2:30:05 PM	41m 5s	victor.adeloran@contoso.example	Attendee
Paul Grennick	10/22/26, 1:14:40 PM	10/22/26, 2:29:55 PM	1h 15m 15s	paul.grennick@contoso.example	Attendee

The page reads 25 lines as a Teams attendance report (tab-separated, several sections; the one with a row per session is used): 7 sessions for 6 people who joined. The slot is 13:00-14:30, 90 min. Of 7 invitees, 6 joined and 1 did not. The most at once: 6 at 13:15; 4 there in the last minute.

The people (RollKit.form), in id order, each sent as one line (RollKit.personLine):

IdPersonJoinedLeftPresentWhat happenedFlagged for
p1Hana Sarvelle12:5814:3290 min (100%)there throughout-
p2Victor Adeloran (needed)13:0014:3088 min (98%)out once (13:47-13:49)dropped
p3Leo Marrowby13:0014:3090 min (100%)there throughout-
p4Ruth Kelvaro13:0114:0059 min (66%)left 30 min earlyearly
p5Ines Morvaine (needed)13:0313:4139 min (43%)left 49 min earlyearly, under half
p6Paul Grennick13:1514:3075 min (84%)15 min latelate
p7Nora Quorvel--0 min (0%)did not joinabsent

Input:

{
 "task": "attendance",
 "goal": "Training on the new quarter-close checklist - Ines and Victor, the two controllers, must be able to run it on their own next week",
 "meeting": "scheduled 13:00-14:30, 90 min; 7 invited, 6 of them joined, 1 did not; most at once: 6 at 13:15; 4 there in the last minute; needed: Victor Adeloran, Ines Morvaine",
 "people": "p1. Hana Sarvelle | joined 12:58 | left 14:32 | present 90 of 90 min (100%)\np2. Victor Adeloran (needed) | joined 13:00 | left 14:30 | present 88 of 90 min (98%) | dropped out once: 13:47-13:49 | missed 2 min\np3. Leo Marrowby | joined 13:00 | left 14:30 | present 90 of 90 min (100%)\np4. Ruth Kelvaro | joined 13:01 | left 14:00, 30 min early | present 59 of 90 min (66%) | missed 31 min\np5. Ines Morvaine (needed) | joined 13:03 | left 13:41, 49 min early | present 39 of 90 min (43%) | missed 51 min\np6. Paul Grennick | joined 13:15, 15 min late | left 14:30 | present 75 of 90 min (84%) | missed 15 min\np7. Nora Quorvel | invited, did not join | missed 90 min",
 "flagged": "p2, p4, p5, p6, p7"
}

Reply:

{
 "task": "attendance",
 "verdict": "gaps",
 "follow_up": [
  {
   "person": "p2",
   "action": "none",
   "why": "Victor Adeloran dropped out once, from 13:47 to 13:49, missing 2 minutes of the meeting."
  },
  {
   "person": "p4",
   "action": "recap",
   "why": "Ruth Kelvaro left at 14:00, 30 minutes early, missing 31 minutes of the meeting."
  },
  {
   "person": "p5",
   "action": "check_in",
   "why": "Ines Morvaine left at 13:41, 49 minutes early, present for 43% of the meeting, missing 51 minutes."
  },
  {
   "person": "p6",
   "action": "recap",
   "why": "Paul Grennick joined 15 minutes late at 13:15 and missed 15 minutes of the meeting."
  },
  {
   "person": "p7",
   "action": "recap",
   "why": "Nora Quorvel did not join and missed the full 90 minutes of the meeting."
  }
 ],
 "message": "Hi Ruth, Paul, and Nora. This covers the training on the new quarter-close checklist. Ruth left at 14:00, 30 minutes early, missing 31 minutes. Paul joined 15 minutes late at 13:15, missing 15 minutes. Nora did not join and missed the full 90 minutes. Notes on what each missed will follow.",
 "note": "The training did not have both controllers it needed for enough time, since Ines Morvaine was present for only 43%, so the first step is a direct conversation with Ines."
}

The follow-ups the page trusts (ReplyKit.trusted), flagged person by person, next to the one its own rule gives (RollKit.expectAction):

PersonWhat happenedMissedFollow-upThe ruleWhy
Victor Adeloran (p2)out once2 minnone (no follow-up)none (no follow-up)Victor Adeloran dropped out once, from 13:47 to 13:49, missing 2 minutes of the meeting.
Ruth Kelvaro (p4)left 30 min early31 minrecap (send the recap)recap (send the recap)Ruth Kelvaro left at 14:00, 30 minutes early, missing 31 minutes of the meeting.
Ines Morvaine (p5)left 49 min early51 mincheck_in (check in)check_in (check in)Ines Morvaine left at 13:41, 49 minutes early, present for 43% of the meeting, missing 51 minutes.
Paul Grennick (p6)15 min late15 minrecap (send the recap)recap (send the recap)Paul Grennick joined 15 minutes late at 13:15 and missed 15 minutes of the meeting.
Nora Quorvel (p7)did not join90 minrecap (send the recap)recap (send the recap)Nora Quorvel did not join and missed the full 90 minutes of the meeting.

The verdict: gaps - "It was missing people it needed"; it agrees with the page's own count.

The recap message: "Hi Ruth, Paul, and Nora. This covers the training on the new quarter-close checklist. Ruth left at 14:00, 30 minutes early, missing 31 minutes. Paul joined 15 minutes late at 13:15, missing 15 minutes. Nora did not join and missed the full 90 minutes. Notes on what each missed will follow."

The note: "The training did not have both controllers it needed for enough time, since Ines Morvaine was present for only 43%, so the first step is a direct conversation with Ines."

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

No notes.

The CSV for the first sample

What RollKit.toCSV(form, t.acts) writes for "A design review (Google Meet)" above: one row per person in id order, with what happened and the trusted follow-up (lines end in CRLF). A person the page did not flag has no follow-up.

id,name,email,invited,needed,joined,left,minutes_present,share_present,minutes_late,minutes_early,drop_outs,minutes_away,status,follow_up
p1,Omar Hadderly,omar.hadderly@northwind.example,yes,,13:58,15:00,60,100%,0,0,0,0,there throughout,
p2,Jonas Brannick,jonas.brannick@northwind.example,yes,,14:01,14:31,31,51%,1,29,0,0,left 29 min early,recap
p3,Priya Nallory,priya.nallory@northwind.example,yes,,14:02,15:00,58,97%,2,0,0,0,there throughout,
p4,Mei Chellow,mei.chellow@northwind.example,yes,,14:03,14:57,38,63%,3,3,0,16,away 16 min,recap
p5,Lena Fennwick,lena.fennwick@northwind.example,yes,yes,14:12,14:59,41,68%,12,2,0,5,"12 min late, away 5 min",recap
p6,Sam Okaverre,sam.okaverre@northwind.example,yes,,,,0,0%,,,0,0,did not join,recap

Build the input yourself

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

const K = RollKit, R = ReplyKit;
const form = K.form({ goal: "Weekly planning - Ana runs it", slot: "10:00-10:30",
  invited: "* Ana Silva\nBen Ito\nCleo Park\nDev Rao",
  report: "Ana Silva 09:59-10:30\nBen Ito 10:08-10:30\nCleo Park 10:00-10:12, 10:15-10:30" });
// form.people: everyone in id order [{id, name, needed, guest, joined, first, last, present, share, drops, flags, flagged}]
// times are seconds after midnight of the meeting's first day (K.hm gives HH:MM); form.curve: the headcount per minute
K.expectVerdict(form); K.expectAction(form.people[0])   // the page's own count and rule, no run needed
const built = K.buildInput(form);     // built.ok is false, with built.note, until someone with a join and a leave is read
// built.flagged: the flagged people; 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));
// const t = R.trusted(reply, built)       // {acts: {p2: action}, why, dropped, missing}
// R.check(reply, built)                    // {status: kept or flagged, notes}
// K.toCSV(form, t.acts)                    // every person with what happened and the follow-up
// RollRegister.draw(form, t.acts, {dark: false})   // the register as an SVG string

Form keys: report (the pasted attendance report, any shape above), invited (the invite list, a * before anyone needed, or empty), slot (the scheduled start and end, such as 14:00-15:00; empty means from the first join to the last leave) and goal (what the meeting was for and who it needed, or empty).

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://who-came.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":"who-came"}' | 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": "attendance", "goal": "Weekly planning - Ana runs it", "meeting": "scheduled 10:00-10:30, 30 min; 4 invited, 3 of them joined, 1 did not; most at once: 3 at 10:08; 3 there in the last minute; needed: Ana Silva", "people": "p1. Ana Silva (needed) | joined 09:59 | left 10:30 | present 30 of 30 min (100%)\np2. Cleo Park | joined 10:00 | left 10:30 | present 27 of 30 min (90%) | dropped out once: 10:12-10:15 | missed 3 min\np3. Ben Ito | joined 10:08, 8 min late | left 10:30 | present 22 of 30 min (73%) | missed 8 min\np4. Dev Rao | invited, did not join | missed 30 min", "flagged": "p2, p3, p4"}
# - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses who-came:<task>:<inputHash>:a<attempt> (the one task is attendance), 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="who-came:attendance:$(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, (.follow_up[] | "\(.person) \(.action)")' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: who-came:attendance:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json