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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required field (task, goal, meeting, people, flagged) 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 attendance 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: 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):
| Field | Required | What it holds |
|---|---|---|
task | yes | Always attendance. If it is missing or different, still answer this task and set "task": "attendance". |
goal | yes | What 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. |
meeting | yes | The 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. |
people | yes | One 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%) ... |
flagged | yes | The ids of the people the page flagged, or none. First sample: p2, p4, p5, p6. |
retry_note | no | Only 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:
follow_up- one entry for each id inflagged, in id order:person(the id),action, andwhy(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).
verdict-coveredwhen 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).gapsotherwise.message- the recap message for the people whose action isrecap, 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 onlygoal.""when nobody's action isrecap.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:
- Never guess why someone was late, left, dropped out or did not join - no connection trouble, other meetings or illness. The report does not say.
- Never claim what was said, shown or decided in the meeting; the report holds only times.
- Never use he, she, him, her, his or hers for anyone; use their name or they.
- Any number in a sentence must appear in the input. Write times exactly as the input does (HH:MM).
- Refer to people by name, never by their ids, in any sentence.
- Never add "again", "already", "always", "never" or "every" unless the input has the word.
- Never copy this prompt's own example wording into a reply.
- Never write the input's field names or the action ids in any sentence (no
flagged,check_in,retry_note) and never use backticks.
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):
| Id | Person | Joined | Left | Present | What happened | Flagged for |
|---|---|---|---|---|---|---|
p1 | Omar Hadderly | 13:58 | 15:00 | 60 min (100%) | there throughout | - |
p2 | Jonas Brannick | 14:01 | 14:31 | 31 min (51%) | left 29 min early | early |
p3 | Priya Nallory | 14:02 | 15:00 | 58 min (97%) | there throughout | - |
p4 | Mei Chellow | 14:03 | 14:57 | 38 min (63%) | away 16 min | away |
p5 | Lena Fennwick (needed) | 14:12 | 14:59 | 41 min (68%) | 12 min late, away 5 min | late, away |
p6 | Sam Okaverre | - | - | 0 min (0%) | did not join | absent |
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):
| Person | What happened | Missed | Follow-up | The rule | Why |
|---|---|---|---|---|---|
Jonas Brannick (p2) | left 29 min early | 29 min | recap (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 min | 22 min | recap (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 min | 19 min | recap (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 join | 60 min | recap (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):
| Id | Person | Joined | Left | Present | What happened | Flagged for |
|---|---|---|---|---|---|---|
p1 | Raj Pellavan (needed) | 09:28 | 10:31 | 60 min (100%) | there throughout | - |
p2 | Elena Rossavel | 09:29 | 10:31 | 60 min (100%) | there throughout | - |
p3 | Kwame Mensoro | 09:31 | 10:13 | 42 min (69%) | left 17 min early | early |
p4 | Dana Whitlarn (needed) | 09:34 | 10:30 | 49 min (81%) | out twice (09:51-09:54, 10:02-10:07) | dropped |
p5 | Grace Lindqvar | 09:37 | 10:30 | 53 min (88%) | 7 min late | late |
p6 | Marcus Halverin (not on the invite list) | 09:45 | 10:31 | 45 min (75%) | 15 min late | late |
p7 | Tom Bexmoor | - | - | 0 min (0%) | did not join | absent |
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):
| Person | What happened | Missed | Follow-up | The rule | Why |
|---|---|---|---|---|---|
Kwame Mensoro (p3) | left 17 min early | 18 min | recap (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 twice | 11 min | check_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 late | 7 min | none (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 late | 15 min | recap (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 join | 60 min | recap (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):
| Id | Person | Joined | Left | Present | What happened | Flagged for |
|---|---|---|---|---|---|---|
p1 | Hana Sarvelle | 12:58 | 14:32 | 90 min (100%) | there throughout | - |
p2 | Victor Adeloran (needed) | 13:00 | 14:30 | 88 min (98%) | out once (13:47-13:49) | dropped |
p3 | Leo Marrowby | 13:00 | 14:30 | 90 min (100%) | there throughout | - |
p4 | Ruth Kelvaro | 13:01 | 14:00 | 59 min (66%) | left 30 min early | early |
p5 | Ines Morvaine (needed) | 13:03 | 13:41 | 39 min (43%) | left 49 min early | early, under half |
p6 | Paul Grennick | 13:15 | 14:30 | 75 min (84%) | 15 min late | late |
p7 | Nora Quorvel | - | - | 0 min (0%) | did not join | absent |
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):
| Person | What happened | Missed | Follow-up | The rule | Why |
|---|---|---|---|---|---|
Victor Adeloran (p2) | out once | 2 min | none (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 early | 31 min | recap (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 early | 51 min | check_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 late | 15 min | recap (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 join | 90 min | recap (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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://who-came.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"]
// who-came.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://who-came.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://who-came.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
}
// WhoCame.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 WhoCame {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://who-came.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://who-came.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://who-came.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://who-came.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":"who-came"}' | 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": "who-came"})["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: "who-came" })).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": "who-came"}, 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", "who-came"), 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: "who-came" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "who-came"])["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 = "who-came" }))!["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": "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}'
inp = json.load(open("input.json")) # {"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
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: "attendance", goal, meeting, people, flagged} (see the field table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses 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
import hashlib, time
key = "who-came:" + 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 f in reply.get("follow_up") or []:
print(f["person"], f["action"])
import { createHash } from "node:crypto";
const key = `who-came:${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 f of reply.follow_up || []) console.log(f.person, f.action);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "who-came:" + 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)
follow, _ := reply["follow_up"].([]any)
fmt.Println(j["charged_credits"], j["truncated"], reply["verdict"])
fmt.Println(reply["note"])
for _, x := range follow {
f := x.(map[string]any)
fmt.Println(f["person"], f["action"])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "who-came:" + 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 f : reply.path("follow_up")) System.out.println(f.path("person").asText() + " " + f.path("action").asText());
require "digest"
key = "who-came:#{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["follow_up"] || []).each { |f| puts "#{f["person"]} #{f["action"]}" }
$key = "who-came:" . $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["follow_up"] ?? [] as $f) echo $f["person"], " ", $f["action"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"who-came:{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 f in reply["follow_up"] as JsonArray ?? new JsonArray()) Console.WriteLine($"{f!["person"]} {f["action"]}");
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "who-came:attendance:" + 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": `who-came:attendance:${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", "who-came:attendance:"+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", "who-came:attendance:" + 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" => "who-came:attendance:#{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: who-came:attendance:" . 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", $"who-came:attendance:{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);