Run Next Thing from your own code
The letter you keep putting off, as one next action. Next Thing takes a letter, an email or a set of instructions someone has to act on - a council form, a school email, a first-week checklist - and shapes it for an ADHD reader the way the source skill says: lead with the next action, number the steps, give minutes instead of "a bit of work", park the tangents, restate where things stand, make finished work visible. The model never invents a date, an amount or a number - every date is a calendar day the page found, and every amount, phone number, link and reference is copied from the text.
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page does the reading for free - it resolves every date to a calendar day (weekdays, "next Friday" and "within 21 days of the date of this letter" counted from the right date), marks each one a deadline, an appointment or a plain date with the days left, finds every ask with a time estimate, what to bring, the amounts, phone numbers, emails, links and reference numbers, and runs the NT- findings (a passed deadline, one within three days, a date that reads two ways, a wall of text, a buried first ask). That is ntscan.js. The run either writes the action card from the text ("plan") or restates the plan after some steps are done ("update"); the page then holds the reply to its sources (ntreply.js): a next action of five minutes or less, timed steps that quote the text, every date one the page found, every deadline carried, every ask a step or parked, every error and warning answered.
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":"next-thing"}), 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 field the run needs (task, text, today, facts) is missing or not a string. Send every field as a string; minutes and progress may be "". |
| 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: both lanes are metered and need 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 comes first
Two lanes - "plan" (the text, as pasted, turned into the action card: a one-line summary, one next action of five minutes or less, at most seven numbered steps each with minutes, a due date and a quote from the text, the deadlines, what to have ready, what is parked under later, at most one question and a last line starting "Next:") and "update" (the same text plus progress - the plan so far with its ticked steps and a note of what happened - turned into the restated card: a state line "Step N of M done: ... Next: ...", the wins, and every step again with a status). Both lanes send the same fields; progress is "" for "plan". A task the model does not know is answered as plan, with a caveat saying so.
The input
Every field is a string.
| Field | Required | What |
|---|---|---|
task | yes | "plan" or "update". The model answers that task and only that task. |
text | yes | the letter, email or instructions, as pasted (line endings normalised to \n, trimmed). The page sends up to 12,000 characters - the letter itself, not the whole thread - and at least a sentence or two. |
today | yes | today's date, YYYY-MM-DD. Weekdays and "tomorrow" are counted from it, and the days left on each deadline. |
minutes | no | how many minutes the reader has right now, digits only ("20"); the first steps should fit in them and the summary says what fits. Send "" when there is none. |
progress | update only | a JSON string (not an object): the plan so far and what happened - see the progress format. Send "" for "plan". |
facts | yes | the page's reading of the same text, built in the browser by ntscan.js (NtScan.facts(), via buildInput) - see the facts format. |
retry_note | no | present only on a reformat retry: the previous reply could not be read as one JSON object, and this says how to answer again. Leave it out otherwise. |
The progress format
For "update", progress carries the steps of the earlier card (each action, minutes - a number, by - a YYYY-MM-DD date or "", and done - a boolean) and note, what happened since in the reader's words. The page builds it with NtScan.progressString(steps, note) and serialises it with JSON.stringify, so it travels as one escaped string. It needs at least one step, and at least one step ticked or a note written; the page keeps 12 steps and 1,500 characters of note.
{"steps": [
{"action": "Sign your contract in DocuSign.", "minutes": 10, "by": "2026-11-01", "done": true},
{"action": "Complete the new starter form at https://people.quillmere.example.com/starter with your bank details and tax code.", "minutes": 25, "by": "", "done": false}
],
"note": "Signed the contract on Saturday. I cannot find my tax code anywhere."}
The facts format
facts is the page's own reading, written as plain text. The page builds it in the browser with ntscan.js (NtScan.buildInput(state, task) calls NtScan.facts(scan, task, state)), and the model may use only the dates it prints: every by and every deadline is one of its calendar days, the card answers every error and warn rule id it lists, and the page then holds the reply to the same text. So an API client should send facts built the same way - from ntscan.js run under node (Build the input yourself; the same text and today give the same facts as the page), or the text the page builds for the same letter - not an empty string and not dates of your own.
Today: <YYYY-MM-DD> (<weekday>). The letter is dated <YYYY-MM-DD>. (or: No letter date found.)
Time the reader has now: <n> minutes. (only when minutes is given)
Read: <n> words, about <n> minutes to read; longest paragraph <n> words.
Dates (text | calendar day | kind | days from today):
- <as written> | <YYYY-MM-DD> (<weekday>) | <deadline|appointment|date> | <n>[ (passed)][ | counted from <date>][ | reads two ways]
Asks the page found (n | verb | page estimate | by | sentence):
- <n> | <verb> | <n> min | <YYYY-MM-DD|-> | <the sentence>
To bring, attach or send: ... . Amounts: ... . Contacts and references: phone ...; email ...; link ...; Reference ... .
Findings (rule | severity | line | where | text):
- <rule id> | <error|warn|info> | line <n> | <where|-> | <title>[ (note)]
The plan so far (<n> of <m> steps done): (update only)
- step <n> | <done|to do> | <action> | <n> min[ | by <YYYY-MM-DD>]
What happened since: <note|(nothing written)>
A list with nothing in it gets the single line - none; the bring, amounts and contacts lines are left out when empty. A real one - the facts the page sends for its "council letter" sample on the "plan" lane (the council, the reader and every number are invented):
Today: 2026-10-30 (friday). The letter is dated 2026-10-21.
Time the reader has now: 20 minutes.
Read: 222 words, about 1 minute to read; longest paragraph 91 words.
Dates (text | calendar day | kind | days from today):
- 1 December 2026 | 2026-12-01 (tuesday) | date | 32
- within 21 days | 2026-11-11 (wednesday) | deadline | 12 | counted from 2026-10-21
Asks the page found (n | verb | page estimate | by | sentence):
- 1 | complete | 30 min | 2026-11-11 | Please complete the enclosed review form and return it to us within 21 days of the date of this letter.
- 2 | include | 10 min | - | You must include your last two payslips or, if you are self-employed, your most recent accounts, and a bank statement for every account held by you or your partner covering the last two months.
- 3 | tell | 5 min | - | If anyone has moved into or out of your home since your last claim, please tell us their name and the date they moved.
- 4 | call | 20 min | - | If you have questions please call us on 0306 999 0123, Monday to Friday 9am to 5pm, quoting your reference number.
To bring, attach or send: last two payslips; most recent accounts; bank statement.
Amounts: GBP 1,842.60.
Contacts and references: phone 0306 999 0123; link www.harrowmere.example.org/ctr-upload; Reference CTR-448812.
Findings (rule | severity | line | where | text):
- NT-RELATIVE | info | line 9 | within 21 days | A deadline counted from another date (counted from the letter's date, 2026-10-21: Wed 11 Nov 2026)
- NT-COND | info | line 11 | tell | An ask that applies only if something is true (ask 3: If anyone has moved into or out of your home since your last claim)
- NT-LONGSENT | info | line 11 | - | Long sentences (1 sentence over 35 words)
- NT-COND | info | line 13 | call | An ask that applies only if something is true (ask 4: If you have questions please call us on 0306 999 0123)
Rule ids the page uses, with their severity and area:
| Rule | Severity | Area | Title |
|---|---|---|---|
NT-PAST | error | dates | A deadline has already passed |
NT-SOON | warn | dates | A deadline is within three days |
NT-AMBIGDATE | warn | dates | A date that reads two ways |
NT-NOYEAR | info | dates | Dates without a year |
NT-WEEKDAY | warn | dates | The weekday and the date disagree |
NT-RELATIVE | info | dates | A deadline counted from another date |
NT-VAGUE | info | dates | A vague time with no date |
NT-WALL | warn | reading | A wall of text |
NT-LONGSENT | info | reading | Long sentences |
NT-BURIED | warn | actions | The first ask is buried |
NT-MANY | warn | actions | More than five things to do |
NT-NOACTION | info | actions | No clear ask found |
NT-COND | info | actions | An ask that applies only if something is true |
NT-NOCONTACT | info | contacts | Asked to call or write, but no number or address |
A point the page cannot see (posting takes days, a partner has to supply statements too) comes back as a finding with the rule NEW.
The standard the model applies
The source skill - @ayghri/i-have-adhd (ayghri/i-have-adhd, MIT) - is the only standard. Its five facts about how an ADHD reader reads:
- Working memory is small: anything not on screen is forgotten. Never ask the reader to "keep in mind" anything.
- Knowing the answer is not doing it: the friction between "got it" and "done it" is where work dies.
- Starting is the hardest step: the first action must be obvious, small and doable now.
- Time estimates feel uniform: "a bit of work" and "a few hours" register the same. Give minutes.
- Dopamine is scarce: visible progress matters; buried wins do not register.
House rules
- Lead with the next action:
next_actionis one thing to do now in five minutes or less (open the form, find the payslips, save the phone number), with itsminutes. Not context, not a plan. - Number the work: the fewest steps that still work, seven at most, each one bounded action with
minutes(a number - a phone call to a council is 20 to 40 minutes with the queue, not "a bit") and asourcequoted exactly from the text (in"update", a step that comes from what happened quotes the note). A passed or near deadline goes first, then what blocks other steps. - The dates belong to the page. Every date is one of the page's calendar days, written
YYYY-MM-DD; a step'sbyis its own deadline or the deadline of the thing it feeds. Every amount, phone number, link and reference number is copied exactly from the text. No date is computed that the page did not find. - Leave nothing out silently: every deadline the page found is in
deadlines(one already met stays, with "(done)"), and every ask is a step or goes inlaterwithwhy(it only applies if something is true, it is optional, or it is information only). Tangents go inlater, never into a step. - Matter-of-fact tone: no preamble ("Great question", "Let me", "Sure"), no recap, no closing pleasantries ("Hope this helps"), no empty hedges ("perhaps", "maybe"), no idioms ("touch base", "dive in"). A problem is stated as cause and fix.
questionis one question, only for real ambiguity the page cannot settle (a passed deadline, a date that reads two ways, a weekday that disagrees with its date); otherwise"".last_lineis one concrete move under two minutes, starting "Next:". Whenminutesis given, the first steps fit in them and the summary says what fits.- Every
errorandwarnfinding in the facts is answered by its rule id inprescan(callagreeordisagree, with anote);infoids need no entry, and with none to answerprescanis[]. - The reply is ONE JSON object and nothing else - no prose around it, no Markdown fences.
The replies
Both lanes carry task, summary, next_action, steps, deadlines, bring, later, question, last_line, findings, prescan and caveats; "update" adds state, wins and a status on every step. The shapes below are what the page reads after NtReply.normalize(): a missing list comes back empty, minutes becomes a whole number (0 when absent), a next_action sent as a bare string becomes {text, minutes: 0}, an unknown severity becomes medium, a call other than disagree becomes agree, an unknown status becomes todo, and steps with no action are dropped. A reply is usable when it has a next action and at least one step.
plan
The action card, written from the text and the facts.
summary- one line: what this text is about and the one date that matters most (and, withminutes, what fits in them).next_action-textandminutes(five or fewer).steps- at most seven, in order, eachaction,minutes,by(a page date or""),source(a quote from the text) andneeds(short words from the text).deadlines- eachdateandwhat;bring- five items or fewer, from the text;later- eachitemandwhy.question;last_line;findings(points the page cannot see:ruleNEW,severityhigh/medium/low,title,where,why,fix);prescan(one entry per distincterrororwarnrule id in the facts:rule,call,note);caveats(strings).
{
"task": "plan",
"summary": "",
"next_action": {"text": "", "minutes": 2},
"steps": [{"action": "", "minutes": 10, "by": "", "source": "", "needs": [""]}],
"deadlines": [{"date": "", "what": ""}],
"bring": [""],
"later": [{"item": "", "why": ""}],
"question": "",
"last_line": "Next: ",
"findings": [{"rule": "NEW", "severity": "medium", "title": "", "where": "", "why": "", "fix": ""}],
"prescan": [{"rule": "NT-SOON", "call": "agree", "note": ""}],
"caveats": [""]
}
update
The plan restated. The reader cannot hold "we are on step 3 of 5" between visits, so the reply says it.
state- one line, "Step N of M done: <what now works>. Next: <the next step>.", where N counts the steps markeddoneand M is the number of steps.wins- what is finished, in concrete terms ("The contract is signed", not "made progress"), one line each.steps- the whole plan again, in order, each withstatus:done(every step the progress marks done stays done, worded as before),todo(unchanged),changed(what happened changes it) ornew(what happened adds it, quoting the note insource). A step that no longer applies goes inlaterwith why.next_action- the first thing of the next step that is not done, five minutes or less;summary- one line, where things stand and what fits inminutes.deadlines,bring(what is still needed),later,question,last_line,findings,prescan,caveats- as for"plan".
{
"task": "update",
"state": "Step 1 of 4 done: . Next: .",
"summary": "",
"wins": [""],
"next_action": {"text": "", "minutes": 2},
"steps": [{"action": "", "minutes": 10, "by": "", "source": "", "needs": [], "status": "done"}],
"deadlines": [{"date": "", "what": ""}],
"bring": [],
"later": [],
"question": "",
"last_line": "Next: ",
"findings": [],
"prescan": [],
"caveats": []
}
The page holds either reply to its sources with NtReply.check() and shows each miss next to the card as a note. It wants no preamble, closer, hedge, idiom or vague estimate in what the reader sees; a next action with a number of minutes, five or fewer; seven steps at most, each with minutes and none chaining several actions; a last line; every by and deadline a YYYY-MM-DD date the page found (or today, or the letter's date); every deadline the page found carried in deadlines or a step's by; every amount and phone number found in the text (or the note); every step not yet done quoting the text or the note; every ask the page found covered by a step, a need, bring or later; every error and warning answered by rule id and no rule id the page did not report other than NEW; and at most one question. For "update" it also wants the state line to say "N of M" and match the steps, every ticked step still shown as done, and a win when steps were done.
Reading the reply
The job's output.output is text that should be exactly one JSON object. Parse it; if a model ever wraps it in prose or a fence, take the text from the first { to the last } and parse that (the page does the same in ntreply.js, and closes a stream that stopped mid-object). If it still does not parse, send the same input again with a retry_note such as "Your previous reply was not usable. Reply again with ONLY the JSON object for task 'plan' - no prose, no code fences." and a new attempt number in the key (see step 5). Step 5 prints the next action first, then the numbered steps with minutes and due dates, the deadlines, what is parked under later and the last line - and for "update" the state line and the wins above them, with each step's status.
Plan, then update
The two lanes chain. A "plan" reply's steps become the checklist; once the reader has ticked some and written what happened, the same text and today go to "update" with progress = NtScan.progressString(reply.steps with a done flag each, note) and fresh facts built for the update lane (they add the plan so far and the note). An "update" reply's steps can be fed back the same way for the next round: a step is done when its status is done.
Worked samples
Two of the page's own samples, with the real saved replies it shows for them. Every council, company, person and number is invented.
Sample 1 - plan: the council letter
A Council Tax Reduction review: a form to return "within 21 days of the date of this letter", payslips and bank statements to include, an only-if ask, a phone number and an upload link. The reader has 20 minutes. The page counts the 21 days from the letter's date to 2026-11-11 and reports four info findings and no warnings. Input - text is exactly what the page sends; facts is shortened here (each ... stands for lines left out - send the whole text the page builds, shown in full under the facts format):
{
"task": "plan",
"text": "Harrowmere District Council - Revenues and Benefits\nDate: 21 October 2026\n\nCouncil Tax Reduction - review of your claim\nReference number: CTR-448812\n\nDear Ms Adeyemi,\n\nWe are reviewing your Council Tax Reduction claim because our records show that your household circumstances may have changed since your last award. To continue your reduction from 1 December 2026 we need some information from you. Please complete the enclosed review form and return it to us within 21 days of the date of this letter. You must include your last two payslips or, if you are self-employed, your most recent accounts, and a bank statement for every account held by you or your partner covering the last two months.\n\nIf anyone has moved into or out of your home since your last claim, please tell us their name and the date they moved. If you do not return the form by the date above, your reduction may stop and you will receive a revised bill for the full amount, which for your property is GBP 1,842.60 for the year.\n\nYou can upload your documents at www.harrowmere.example.org/ctr-upload or post them to Revenues and Benefits, PO Box 117, Harrowmere WB1 4QT. If you have questions please call us on 0306 999 0123, Monday to Friday 9am to 5pm, quoting your reference number.\n\nYours sincerely,\nBenefits Review Team",
"today": "2026-10-30",
"minutes": "20",
"progress": "",
"facts": "Today: 2026-10-30 (friday). The letter is dated 2026-10-21.\nTime the reader has now: 20 minutes.\n...\nDates (text | calendar day | kind | days from today):\n- 1 December 2026 | 2026-12-01 (tuesday) | date | 32\n- within 21 days | 2026-11-11 (wednesday) | deadline | 12 | counted from 2026-10-21\nAsks the page found (n | verb | page estimate | by | sentence):\n- 1 | complete | 30 min | 2026-11-11 | Please complete the enclosed review form and return it to us within 21 days of the date of this letter.\n...\nContacts and references: phone 0306 999 0123; link www.harrowmere.example.org/ctr-upload; Reference CTR-448812.\nFindings (rule | severity | line | where | text):\n- NT-RELATIVE | info | line 9 | within 21 days | A deadline counted from another date (counted from the letter's date, 2026-10-21: Wed 11 Nov 2026)\n..."
}
Reply - the saved reply, abbreviated: steps keeps 2 of 4 and later 2 of 4 (the full reply goes on with filling in the form, 30 minutes, and uploading it, 10 minutes, both by 2026-11-11, and also has a fourth bring item and a second NEW finding - the partner's statements are needed too):
{
"task": "plan",
"summary": "Council Tax Reduction review (ref CTR-448812): return the form and documents by 2026-11-11. In your 20 minutes, steps 1 and 2 fit: gather payslips and bank statements.",
"next_action": {"text": "Find the enclosed review form and put it on your desk with this letter.", "minutes": 2},
"steps": [
{"action": "Find your last two payslips (or your most recent accounts if self-employed).", "minutes": 10, "by": "2026-11-11", "source": "your last two payslips or, if you are self-employed, your most recent accounts", "needs": ["last two payslips", "most recent accounts"]},
{"action": "Download a bank statement covering the last two months for every account held by you or your partner.", "minutes": 10, "by": "2026-11-11", "source": "a bank statement for every account held by you or your partner covering the last two months", "needs": ["bank statement"]}
],
"deadlines": [
{"date": "2026-11-11", "what": "Return the completed review form and documents"},
{"date": "2026-12-01", "what": "Reduction continues from this date if the review is complete"}
],
"bring": ["review form", "last two payslips (or most recent accounts if self-employed)", "bank statements for the last two months, every account of you and your partner"],
"later": [
{"item": "If anyone moved into or out of your home since your last claim, give their name and the date they moved.", "why": "Only applies if someone moved in or out."},
{"item": "Call 0306 999 0123, Monday to Friday 9am to 5pm, quoting CTR-448812.", "why": "Only needed if you have questions."}
],
"question": "",
"last_line": "Next: Save 0306 999 0123 in your phone as Council Tax, with CTR-448812 in the notes.",
"findings": [
{"rule": "NEW", "severity": "medium", "title": "Posting takes days", "where": "post them to Revenues and Benefits, PO Box 117", "why": "The form has to reach the council by 2026-11-11; a posted form can arrive late.", "fix": "Upload at www.harrowmere.example.org/ctr-upload, or post well before 2026-11-11 and keep proof of postage."}
],
"prescan": [],
"caveats": ["Payslips or accounts: which one depends on whether you are employed or self-employed."]
}
The next action takes two minutes and needs nothing; the steps are timed (filling in the form keeps the page's 30 minutes) and each quotes the letter; both of the page's dates come back as YYYY-MM-DD, the only-if asks and the phone number are parked under later with why, the amount and the phone number are copied exactly, and with only info findings prescan is empty. The page's reply check finds nothing to note.
Sample 2 - update: a new job, half done
A first-week email from an employer: six asks, two deadlines within three days. The reader has ticked two steps of the earlier card and written what happened: the 9:30 laptop slots were gone, so 11:00 was booked, and the tax code the starter form needs cannot be found. The page reports two warnings (NT-MANY, NT-SOON). Input - text and progress are exactly what the page sends; facts is shortened (send the whole text, about 2,900 characters):
{
"task": "update",
"text": "Subject: Welcome to Quillmere Analytics - your first week\n\nHi Sam,\n\nWelcome aboard! We are really happy you are joining us on Monday 2 November. A few things to get through before and during your first week:\n\n- Sign your contract in DocuSign (link sent separately) by Sunday 1 November.\n- Complete the new starter form at https://people.quillmere.example.com/starter so payroll can set you up - we need your bank details and tax code.\n- Bring your passport or birth certificate on Monday for the right-to-work check.\n- Book a 30 minute equipment pickup with IT at https://it.quillmere.example.com/book - laptops go out from 9:30 on Monday.\n- Read the engineering handbook before your Wednesday onboarding session (4 November at 2pm).\n- Set up two-factor authentication on your Google account once you have your laptop.\n\nYour buddy is Ilsa Marrowby (ilsa.marrowby@quillmere.example.com) - she will take you for lunch on Monday. If anything comes up, call me on +44 20 7946 0321.\n\nSee you soon,\nTobin Ashgrove, People Team",
"today": "2026-10-30",
"minutes": "45",
"progress": "{\"steps\":[{\"action\":\"Sign your contract in DocuSign.\",\"minutes\":10,\"by\":\"2026-11-01\",\"done\":true},{\"action\":\"Book a 30 minute equipment pickup slot for Monday at https://it.quillmere.example.com/book (laptops go out from 9:30).\",\"minutes\":5,\"by\":\"2026-11-02\",\"done\":true},{\"action\":\"Complete the new starter form at https://people.quillmere.example.com/starter with your bank details and tax code.\",\"minutes\":25,\"by\":\"\",\"done\":false},{\"action\":\"Put your passport or birth certificate in the bag you take on Monday.\",\"minutes\":10,\"by\":\"2026-11-02\",\"done\":false},{\"action\":\"Set up two-factor authentication on your Google account on the new laptop.\",\"minutes\":15,\"by\":\"\",\"done\":false},{\"action\":\"Read the engineering handbook.\",\"minutes\":60,\"by\":\"2026-11-04\",\"done\":false}],\"note\":\"Signed the contract on Saturday. The 9:30 laptop slots were gone, so I booked 11:00 on Monday instead. I cannot find my tax code anywhere.\"}",
"facts": "Today: 2026-10-30 (friday). No letter date found.\nTime the reader has now: 45 minutes.\n...\nFindings (rule | severity | line | where | text):\n- NT-MANY | warn | line 0 | - | More than five things to do (6 asks in the text)\n- NT-SOON | warn | line 7 | 1 November | A deadline is within three days (Sun 1 Nov 2026, in 2 days)\n- NT-SOON | warn | line 10 | on Monday | A deadline is within three days (Mon 2 Nov 2026, in 3 days)\n- NT-NOYEAR | info | line 5 | 2 November, 1 November, 4 November | Dates without a year (3 dates read as 2026-11-02, 2026-11-01, 2026-11-04)\nThe plan so far (2 of 6 steps done):\n- step 1 | done | Sign your contract in DocuSign. | 10 min | by 2026-11-01\n- step 2 | done | Book a 30 minute equipment pickup slot for Monday at https://it.quillmere.example.com/book (laptops go out from 9:30). | 5 min | by 2026-11-02\n...\nWhat happened since: Signed the contract on Saturday. The 9:30 laptop slots were gone, so I booked 11:00 on Monday instead. I cannot find my tax code anywhere."
}
Reply - the saved reply, abbreviated: steps keeps 4 of 7 (the two done, the new one and the changed one; the full reply goes on with the passport, the two-factor setup and the handbook, all todo), and the full reply also has a second later item, a second NEW finding and a second caveat:
{
"task": "update",
"state": "Step 2 of 7 done: the contract is signed and the laptop pickup is booked. Next: find your tax code.",
"wins": ["The contract is signed in DocuSign.", "The IT laptop pickup is booked for 11:00 on Monday 2026-11-02."],
"next_action": {"text": "Open your last payslip or P45 and look for the tax code.", "minutes": 5},
"steps": [
{"action": "Sign your contract in DocuSign.", "minutes": 10, "by": "2026-11-01", "source": "Sign your contract in DocuSign (link sent separately) by Sunday 1 November.", "needs": [], "status": "done"},
{"action": "Book a 30 minute equipment pickup slot for Monday at https://it.quillmere.example.com/book (laptops go out from 9:30).", "minutes": 5, "by": "2026-11-02", "source": "Book a 30 minute equipment pickup with IT at https://it.quillmere.example.com/book", "needs": [], "status": "done"},
{"action": "Find your tax code on your last payslip, P45 or P60.", "minutes": 15, "by": "", "source": "I cannot find my tax code anywhere.", "needs": ["last payslip, P45 or P60"], "status": "new"},
{"action": "Complete the new starter form at https://people.quillmere.example.com/starter with your bank details and the tax code you found.", "minutes": 25, "by": "", "source": "Complete the new starter form at https://people.quillmere.example.com/starter so payroll can set you up - we need your bank details and tax code.", "needs": ["bank details", "tax code"], "status": "changed"}
],
"deadlines": [
{"date": "2026-11-01", "what": "Contract signed in DocuSign (done)"},
{"date": "2026-11-02", "what": "First day: right-to-work check, laptop pickup at 11:00"},
{"date": "2026-11-04", "what": "Onboarding session at 2pm; handbook read before it"}
],
"bring": ["passport or birth certificate", "bank details", "tax code"],
"later": [
{"item": "If the tax code is not on any payslip, P45 or P60, call Tobin Ashgrove on +44 20 7946 0321 and ask what to put on the starter form.", "why": "Only applies if the tax code cannot be found."}
],
"question": "",
"last_line": "Next: put your passport or birth certificate in your Monday bag.",
"findings": [
{"rule": "NEW", "severity": "medium", "title": "Missing tax code blocks the starter form", "where": "progress note", "why": "The form needs the tax code so payroll can set you up; without it the form stays unfinished.", "fix": "Find the tax code on a payslip, P45 or P60 first; if it is not there, call +44 20 7946 0321."}
],
"prescan": [
{"rule": "NT-MANY", "call": "agree", "note": "Six asks plus one new step from the note; two are done, five are left."},
{"rule": "NT-SOON", "call": "agree", "note": "The 2026-11-01 contract deadline is met. The 2026-11-02 items (passport or birth certificate, 11:00 pickup) are in 3 days."}
],
"caveats": ["With 45 minutes now: finding the tax code (15) and the starter form (25) fit, 40 minutes in total."]
}
"Step 2 of 7 done" matches the seven steps, two of them done and worded as before; the missing tax code becomes a new step quoting the note, and the starter form that depends on it is changed. The met deadline stays, marked "(done)"; both warning ids (NT-MANY, NT-SOON, the latter reported twice) are answered once each in prescan, and the info id needs no entry. The page's reply check finds nothing to note.
Build the input yourself
The reading and the reply checker are plain scripts that run under node (global.window = global; require("./ntscan.js"); require("./ntreply.js"), from the app at https://next-thing.skillsafe.ai/ntscan.js and /ntreply.js). The same text and today give the same facts as the page - both samples above reproduce byte for byte:
const S = NtScan, R = NtReply;
const st = { text: fs.readFileSync("letter.txt", "utf8"), today: "2026-10-30", minutes: "20", progress: "" };
const b = S.buildInput(st, "plan"); // or "update" with st.progress; b.ok is false (with b.note) when the text cannot run
console.log(b.scan.deadlines.map(d => d.iso), b.scan.findings.map(f => f.id)); // [ '2026-11-11' ] [ 'NT-RELATIVE', ... ]
const wire = S.toWire(b.input); // task, text, today, minutes, progress, facts - all strings; POST this to /estimate and /run
// for update: st.progress = S.progressString(steps.map(x => ({...x, done: ticked(x)})), "what happened")
// then: const reply = R.normalize(R.parseReply(job.output.output), "plan");
// R.check(reply, b) // {notes, warn}: dates the page did not find, lost deadlines, dropped asks, unanswered findings
// R.md("Council Tax Reduction", b, reply) // the card and the page's findings as Markdown
Get a token for the steps below from /tokens.html.
Step 1 - a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html in place of YOUR_TOKEN, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://next-thing.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 = "YOUR_TOKEN" # from https://next-thing.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"]
// next-thing.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://next-thing.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 = "YOUR_TOKEN" // from https://next-thing.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
}
// NextThing.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 NextThing {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://next-thing.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 = +"YOUR_TOKEN" # from https://next-thing.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, 443, 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 = "YOUR_TOKEN"; // from https://next-thing.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 = "YOUR_TOKEN"; // from https://next-thing.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":"next-thing"}' | 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": "next-thing"})["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: "next-thing" })).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": "next-thing"}, 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", "next-thing"), 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: "next-thing" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "next-thing"])["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 = "next-thing" }))!["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. Save a worked-sample input above as input.json (task "plan" with the text, or "update" with the text and a progress string, each with the whole facts the page builds for it) to follow along.
# input.json: the run input object - {"task": "plan", "text": "Harrowmere District Council ...", "today": "2026-10-30", "minutes": "20", "progress": "", "facts": "Today: ...\nDates ...\nFindings ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "plan", "text": "...", "today": "2026-10-30", "minutes": "", "progress": "", "facts": "Today: ..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "plan" or "update", text, today, minutes, progress, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 - POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses next-thing:<task>:<inputHash>:a<attempt> - the lane is part of the key, so a plan and an update are two runs - and its one automatic reformat retry (sent with a retry_note, which the hash leaves out) is a new attempt number, a2, named in the page as one extra run. Any stable hash of the input works for your own key. Each run is metered on gpt-terra; the job reports charged_credits, and truncated is true when the available balance cut the reply short. The code below parses the JSON object out of the output and prints, for "update", the state line and the wins, then for both lanes the next action with its minutes, the summary, the numbered steps (with each step's status on an update), the deadlines, what is parked under later, the question and the last line.
KEY="next-thing:$(jq -r .task input.json):$(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 '(if .task == "update" then "STATE: \(.state)", (.wins[]? | "+ \(.)") else empty end),
"NEXT (\(.next_action.minutes) min): \(.next_action.text)", .summary, "",
(.steps | to_entries[]? | " \(.key + 1). \(if .value.status then "[\(.value.status)] " else "" end)\(.value.action) - \(.value.minutes) min\(if (.value.by // "") != "" then ", by \(.value.by)" else "" end)"),
"Deadlines:", (.deadlines[]? | " \(.date) - \(.what)"),
(.later[]? | "later: \(.item) - \(.why)"),
(if (.question // "") != "" then "? \(.question)" else empty end), .last_line' reply.json
import hashlib, time
key = "next-thing:" + 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)
out = j["output"]["output"]
reply = json.loads(out[out.index("{"):out.rindex("}") + 1]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"))
if reply.get("task") == "update":
print("STATE:", reply.get("state", ""))
for w in reply.get("wins", []):
print("+", w)
na = reply.get("next_action") or {}
print(f"NEXT ({na.get('minutes', '')} min): {na.get('text', '')}")
print(reply.get("summary", ""), "\n")
for i, s in enumerate(reply.get("steps", []), 1):
status = f"[{s['status']}] " if s.get("status") else ""
by = f", by {s['by']}" if s.get("by") else ""
print(f" {i}. {status}{s['action']} - {s.get('minutes', '')} min{by}")
print("Deadlines:")
for d in reply.get("deadlines", []):
print(f" {d.get('date', '')} - {d.get('what', '')}")
for x in reply.get("later", []):
print(f"later: {x['item']} - {x.get('why', '')}")
if reply.get("question"):
print("?", reply["question"])
print(reply.get("last_line", ""))
import { createHash } from "node:crypto";
const key = `next-thing:${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 out = j.output.output;
const reply = JSON.parse(out.slice(out.indexOf("{"), out.lastIndexOf("}") + 1));
console.log(j.charged_credits, j.truncated);
if (reply.task === "update") {
console.log("STATE:", reply.state || "");
for (const w of reply.wins || []) console.log("+", w);
}
const na = reply.next_action || {};
console.log(`NEXT (${na.minutes || ""} min): ${na.text || ""}`);
console.log((reply.summary || "") + "\n");
(reply.steps || []).forEach((s, i) =>
console.log(` ${i + 1}. ${s.status ? "[" + s.status + "] " : ""}${s.action} - ${s.minutes || ""} min${s.by ? ", by " + s.by : ""}`));
console.log("Deadlines:");
for (const d of reply.deadlines || []) console.log(` ${d.date || ""} - ${d.what || ""}`);
for (const x of reply.later || []) console.log(`later: ${x.item} - ${x.why || ""}`);
if (reply.question) console.log("?", reply.question);
console.log(reply.last_line || "");
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "strings", "time" to the imports
key := "next-thing:" + 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 }
}
out := j["output"].(map[string]any)["output"].(string)
var reply map[string]any
json.Unmarshal([]byte(out[strings.Index(out, "{"):strings.LastIndex(out, "}")+1]), &reply)
fmt.Println(j["charged_credits"], j["truncated"])
list := func(v any) []any { a, _ := v.([]any); return a }
obj := func(v any) map[string]any { o, _ := v.(map[string]any); if o == nil { o = map[string]any{} }; return o }
if reply["task"] == "update" {
fmt.Printf("STATE: %v\n", reply["state"])
for _, w := range list(reply["wins"]) { fmt.Println("+", w) }
}
na := obj(reply["next_action"])
fmt.Printf("NEXT (%v min): %v\n%v\n\n", na["minutes"], na["text"], reply["summary"])
for i, x := range list(reply["steps"]) {
s := obj(x); status, by := "", ""
if v, ok := s["status"].(string); ok && v != "" { status = "[" + v + "] " }
if v, ok := s["by"].(string); ok && v != "" { by = ", by " + v }
fmt.Printf(" %d. %s%v - %v min%s\n", i+1, status, s["action"], s["minutes"], by)
}
fmt.Println("Deadlines:")
for _, x := range list(reply["deadlines"]) { d := obj(x); fmt.Printf(" %v - %v\n", d["date"], d["what"]) }
for _, x := range list(reply["later"]) { l := obj(x); fmt.Printf("later: %v - %v\n", l["item"], l["why"]) }
if q, ok := reply["question"].(string); ok && q != "" { fmt.Println("?", q) }
fmt.Println(reply["last_line"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "next-thing:" + 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()));
String out = j.get("output").get("output").asText();
JsonNode reply = M.readTree(out.substring(out.indexOf('{'), out.lastIndexOf('}') + 1));
System.out.println(j.get("charged_credits") + " " + j.get("truncated"));
if ("update".equals(reply.path("task").asText())) {
System.out.println("STATE: " + reply.path("state").asText());
for (JsonNode w : reply.path("wins")) System.out.println("+ " + w.asText());
}
JsonNode na = reply.path("next_action");
System.out.println("NEXT (" + na.path("minutes").asText() + " min): " + na.path("text").asText());
System.out.println(reply.path("summary").asText() + "\n");
int i = 0;
for (JsonNode s : reply.path("steps")) {
String status = s.path("status").asText().isEmpty() ? "" : "[" + s.path("status").asText() + "] ";
String by = s.path("by").asText().isEmpty() ? "" : ", by " + s.path("by").asText();
System.out.println(" " + (++i) + ". " + status + s.path("action").asText() + " - " + s.path("minutes").asText() + " min" + by);
}
System.out.println("Deadlines:");
for (JsonNode d : reply.path("deadlines")) System.out.println(" " + d.path("date").asText() + " - " + d.path("what").asText());
for (JsonNode x : reply.path("later")) System.out.println("later: " + x.path("item").asText() + " - " + x.path("why").asText());
if (!reply.path("question").asText().isEmpty()) System.out.println("? " + reply.path("question").asText());
System.out.println(reply.path("last_line").asText());
require "digest"
key = "next-thing:#{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
out = j["output"]["output"]
reply = JSON.parse(out[out.index("{")..out.rindex("}")])
puts [j["charged_credits"], j["truncated"]].inspect
if reply["task"] == "update"
puts "STATE: #{reply["state"]}"
(reply["wins"] || []).each { |w| puts "+ #{w}" }
end
na = reply["next_action"] || {}
puts "NEXT (#{na["minutes"]} min): #{na["text"]}", reply["summary"], ""
(reply["steps"] || []).each_with_index do |s, i|
status = s["status"].to_s.empty? ? "" : "[#{s["status"]}] "
by = s["by"].to_s.empty? ? "" : ", by #{s["by"]}"
puts " #{i + 1}. #{status}#{s["action"]} - #{s["minutes"]} min#{by}"
end
puts "Deadlines:"
(reply["deadlines"] || []).each { |d| puts " #{d["date"]} - #{d["what"]}" }
(reply["later"] || []).each { |x| puts "later: #{x["item"]} - #{x["why"]}" }
puts "? #{reply["question"]}" unless reply["question"].to_s.empty?
puts reply["last_line"]
$key = "next-thing:" . $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"]));
$out = $j["output"]["output"];
$from = strpos($out, "{");
$reply = json_decode(substr($out, $from, strrpos($out, "}") - $from + 1), true);
echo $j["charged_credits"], "\n";
if (($reply["task"] ?? "") === "update") {
echo "STATE: ", $reply["state"] ?? "", "\n";
foreach ($reply["wins"] ?? [] as $w) echo "+ ", $w, "\n";
}
$na = $reply["next_action"] ?? [];
echo "NEXT (", $na["minutes"] ?? "", " min): ", $na["text"] ?? "", "\n", $reply["summary"] ?? "", "\n\n";
foreach ($reply["steps"] ?? [] as $i => $s) {
$status = empty($s["status"]) ? "" : "[" . $s["status"] . "] ";
$by = empty($s["by"]) ? "" : ", by " . $s["by"];
echo " ", $i + 1, ". ", $status, $s["action"], " - ", $s["minutes"] ?? "", " min", $by, "\n";
}
echo "Deadlines:\n";
foreach ($reply["deadlines"] ?? [] as $d) echo " ", $d["date"] ?? "", " - ", $d["what"] ?? "", "\n";
foreach ($reply["later"] ?? [] as $x) echo "later: ", $x["item"], " - ", $x["why"] ?? "", "\n";
if (!empty($reply["question"])) echo "? ", $reply["question"], "\n";
echo $reply["last_line"] ?? "", "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"next-thing:{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 output = j["output"]!["output"]!.GetValue<string>();
var from = output.IndexOf('{');
var reply = JsonNode.Parse(output.Substring(from, output.LastIndexOf('}') - from + 1))!;
Console.WriteLine($"{j["charged_credits"]} {j["truncated"]}");
JsonArray Arr(JsonNode? n) => n as JsonArray ?? new JsonArray();
string Str(JsonNode? n) => n?.ToString() ?? "";
if (Str(reply["task"]) == "update") {
Console.WriteLine($"STATE: {Str(reply["state"])}");
foreach (var w in Arr(reply["wins"])) Console.WriteLine($"+ {w}");
}
var na = reply["next_action"];
Console.WriteLine($"NEXT ({Str(na?["minutes"])} min): {Str(na?["text"])}\n{Str(reply["summary"])}\n");
var i = 0;
foreach (var s in Arr(reply["steps"])) {
var status = Str(s!["status"]) == "" ? "" : $"[{s["status"]}] ";
var by = Str(s["by"]) == "" ? "" : $", by {s["by"]}";
Console.WriteLine($" {++i}. {status}{s["action"]} - {Str(s["minutes"])} min{by}");
}
Console.WriteLine("Deadlines:");
foreach (var d in Arr(reply["deadlines"])) Console.WriteLine($" {d!["date"]} - {d["what"]}");
foreach (var x in Arr(reply["later"])) Console.WriteLine($"later: {x!["item"]} - {x["why"]}");
if (Str(reply["question"]) != "") Console.WriteLine($"? {reply["question"]}");
Console.WriteLine(Str(reply["last_line"]));
Step 6 - POST /run-stream
The same run as an SSE stream, with the same input.json and the same kind of key. Browsers receive progress ticks rather than text deltas; the final done message carries the job, whose output.output is the reply.
# SSE: tick/delta messages while the job runs, then a final message with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: next-thing:$(jq -r .task input.json):$(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 (see the input table)
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "next-thing:" + rev["task"] + ":" + 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": `next-thing:${rev.task}:${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" 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", "next-thing:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<24) // the final message carries the whole reply
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", "next-thing:" + M.readTree(rev).path("task").asText() + ":" + 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" => "next-thing:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, 443, 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");
$task = json_decode($rev, true)["task"];
$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: next-thing:$task:" . 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", $"next-thing:{JsonNode.Parse(rev)!["task"]}:{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);