Drive Spot the Change from your own code
The one metered task, changes, is available over HTTP. It takes the exact difference between two versions of a table - every changed cell, added or removed row and added or removed column, one numbered line each (c1, c2 ...) - with what the table is, its columns, the key column and the counts, and returns the changes grouped into themes a person would recognise, the changes that look like a mistake or deserve a second look, a short summary, a note and a verdict.
The division of labour first: the page compares the two tables exactly and numbers every change; the model groups the changes into themes and flags what looks like a mistake, referring to changes only by id so the page shows the real values. The page reads each table as CSV, tab-separated or semicolon-separated text with a header row, keeps up to 500 rows and 20 columns of each, picks the column that identifies a row (or matches rows by position), and sends up to 80 change lines. After the reply it keeps only the ids it sent (each change in one group, leftovers gathered under "Other changes"), re-checks every number the reply quotes against the change lines, and builds the change log. Your code can do the same with the engine files the page loads (see "Build the input yourself").
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response uses one envelope:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"spot-the-change"}), 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, columns, key, counts, changes, change_count, not_sent) 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 changes 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: changes (the one task is changes); a missing or different one is still answered as changes. All fields are strings.
| Field | Required | What it holds |
|---|---|---|
task | yes | Always changes. |
goal | yes | What the table is, in the person's words (at most 160 characters): Our bakery's price list, last month against this month, or none. |
columns | yes | The columns of the newer table, joined with , : SKU, Item, Price, Stock, Allergens. |
key | yes | The column that identifies each row in both tables (SKU), or none (rows matched by position). |
counts | yes | The page's own counts as one line: 7 rows before, 7 after; 5 changed cells in 5 rows, 1 row added, 1 removed, 1 column added, 0 removed. |
changes | yes | One line per change, at most 80, each starting with its id: c2. B01 | Price: "4.20" -> "4.40" (+0.2, +4.8%) for a changed cell (the difference and percentage only for numbers), c8. added row C04: SKU = C04; Item = Almond croissant; ..., c7. removed row C03: ..., c1. added column Allergens or removed column .... Columns come first, then the rows of the older table in order, then the added rows. |
change_count | yes | How many change lines were sent, as a string (8). |
not_sent | yes | How many more changes the page found but did not send, as a string (0). |
retry_note | no | Only on a reformat retry: why the previous reply was unusable. |
How the page reads and compares (DiffKit): the separator is whichever of tab, comma and semicolon is commonest in the first line; quoted fields and "" escapes work; blank header cells become Column N; cells are clipped to 60 characters. Columns are matched by name, ignoring case and punctuation. The key is a column present in both tables whose values are filled and unique in both and shared by at least half the rows - a named choice first, then id-, code-, SKU- or name-like headers, then the first column that qualifies; with no key, rows are matched by position. A number such as £1,200.50, 12% or (40) is read as a number, so its change gets a difference and a percentage. The page needs both tables with a header and at least one row, and at least one change, before it will send.
The model's rules: every id goes into exactly one group (one to six groups, largest first, each with a title of at most 8 words and a one-sentence reason quoting values from the change lines). A change is flagged when a number is far out of line with similar changes in the same column (about ten times or a tenth, an extra or missing digit), when a value is impossible for its column (a negative count or price, a date that cannot exist), when a row appears or disappears where the goal suggests every row should still be there, or when an identifying value changes spelling slightly - never only because it is large when the rest explains it. Sentences refer to rows and columns, never to ids, and quote only numbers that appear in the change lines or the counts. The verdict is check when there is at least one flag, otherwise routine.
The reply
One JSON object:
{"task": "changes", "verdict": "check", "summary": "...", "groups": [{"title": "...", "ids": ["c2", "c3"], "why": "..."}], "flags": [{"id": "c5", "why": "..."}], "note": "..."}
| Field | What it holds |
|---|---|
verdict | check (at least one flag) or routine (none). |
summary | At most two sentences: what changed overall, naming the biggest groups, with counts that match counts. |
groups[].title | At most 8 words, in the table's own words. |
groups[].ids | The change ids in this group, in their own order. Every id sent belongs to exactly one group. |
groups[].why | One sentence: what these changes have in common, quoting values from the change lines. |
flags[] | {id, why}, one per change that looks like a mistake or deserves a second look; why names the row and quotes the old and new values. [] when nothing looks off. |
note | One sentence: for check, which changes to look at again and why; for routine, that nothing looks out of line. |
The page reads the reply with ReplyKit.normalize (ids are matched as c plus a number, any case) and keeps what it trusts with ReplyKit.trusted(reply, sent): only ids it sent, each in the first group that names it; every sent id no group named is gathered under "Other changes"; flags only for ids it sent, once each. It then shows each change with its real values (ReplyKit.plain), so a reply can never misquote a cell.
ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one that disagrees with the flags (check with no trusted flag, routine with one); ids the page never found; changes left out of every group, or put in more than one; more than six groups, a long group title or a group with no reason; any number in the summary, the note, a title or a reason that appears in none of the change lines or the counts; a sentence that names a change id or an internal field; words such as "still" or "always" the change lines and goal never used; and changes found but not sent. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "Worth a second look" or "Nothing looks out of line".
The downloads: the change log (ReplyKit.csv(form, reply, sent)) is every change the page found - id, what, row, column, before, after, change, percent - with the reply's group and its look_again reason added as two more columns; ReplyKit.md writes the same as a Markdown report with the flagged changes first.
Worked examples - every sample
These are the free samples on the page: the two pasted tables, the change lines and the exact input the page sends, the reply that replays for free, and what the page does with it - each trusted group with the real values of its changes, the flagged changes and its own check. Together they cover the verdicts check and routine.
A bakery price list
The two tables as pasted (before, then after):
SKU,Item,Price,Stock
B01,Sourdough loaf,4.20,12
B02,"Rye, dark",3.80,8
B03,Seeded batch,4.00,10
C01,Croissant,1.60,30
C02,Pain au chocolat,1.90,24
C03,Cinnamon bun,2.20,20
D01,Flapjack,1.80,15
SKU,Item,Price,Stock,Allergens
B01,Sourdough loaf,4.40,12,gluten
B02,"Rye, dark",4.00,8,gluten
B03,Seeded batch,4.20,10,"gluten, sesame"
C01,Croissant,16.00,30,gluten
C02,Pain au chocolat,2.00,24,"gluten, milk"
D01,Flapjack,1.80,15,oats
C04,Almond croissant,2.40,18,"gluten, nuts"
The key the page picks (DiffKit.pickKey): SKU. The change lines it sends (DiffKit.line):
c1. added column Allergens
c2. B01 | Price: "4.20" -> "4.40" (+0.2, +4.8%)
c3. B02 | Price: "3.80" -> "4.00" (+0.2, +5.3%)
c4. B03 | Price: "4.00" -> "4.20" (+0.2, +5%)
c5. C01 | Price: "1.60" -> "16.00" (+14.4, +900%)
c6. C02 | Price: "1.90" -> "2.00" (+0.1, +5.3%)
c7. removed row C03: SKU = C03; Item = Cinnamon bun; Price = 2.20; Stock = 20
c8. added row C04: SKU = C04; Item = Almond croissant; Price = 2.40; Stock = 18; Allergens = gluten, nuts
Input:
{
"task": "changes",
"goal": "Our bakery's price list, last month against this month",
"columns": "SKU, Item, Price, Stock, Allergens",
"key": "SKU",
"counts": "7 rows before, 7 after; 5 changed cells in 5 rows, 1 row added, 1 removed, 1 column added, 0 removed",
"changes": "c1. added column Allergens\nc2. B01 | Price: \"4.20\" -> \"4.40\" (+0.2, +4.8%)\nc3. B02 | Price: \"3.80\" -> \"4.00\" (+0.2, +5.3%)\nc4. B03 | Price: \"4.00\" -> \"4.20\" (+0.2, +5%)\nc5. C01 | Price: \"1.60\" -> \"16.00\" (+14.4, +900%)\nc6. C02 | Price: \"1.90\" -> \"2.00\" (+0.1, +5.3%)\nc7. removed row C03: SKU = C03; Item = Cinnamon bun; Price = 2.20; Stock = 20\nc8. added row C04: SKU = C04; Item = Almond croissant; Price = 2.40; Stock = 18; Allergens = gluten, nuts",
"change_count": "8",
"not_sent": "0"
}
Reply:
{
"task": "changes",
"verdict": "check",
"summary": "Price rose for B01, B02, B03 and C02, and an Allergens column was added, among 5 changed cells in 5 rows and 1 column added. C03 Cinnamon bun was removed and C04 Almond croissant was added, 1 row added and 1 removed.",
"groups": [
{
"title": "Price increased for B01, B02, B03, C02",
"ids": [
"c2",
"c3",
"c4",
"c6"
],
"why": "B01, B02 and B03 rose by +0.2 (+4.8%, +5.3%, +5%), and C02 rose by +0.1 (+5.3%)."
},
{
"title": "Cinnamon bun removed, Almond croissant added",
"ids": [
"c7",
"c8"
],
"why": "Cinnamon bun (SKU C03) was removed while Almond croissant (SKU C04) was added at Price 2.40 and Stock 18."
},
{
"title": "New Allergens column added",
"ids": [
"c1"
],
"why": "Allergens, a new column, was added to the table."
},
{
"title": "Price change for C01",
"ids": [
"c5"
],
"why": "C01 price went from 1.60 to 16.00, up +14.4 (+900%)."
}
],
"flags": [
{
"id": "c5",
"why": "C01 price went from 1.60 to 16.00 (+900%), far larger than other Price changes."
}
],
"note": "Look at the C01 price change from 1.60 to 16.00, since it is far larger than the other Price increases in the table."
}
The groups the page keeps (ReplyKit.trusted), each change shown with its real values (ReplyKit.plain):
Price increased for B01, B02, B03, C02 - B01, B02 and B03 rose by +0.2 (+4.8%, +5.3%, +5%), and C02 rose by +0.1 (+5.3%).
- B01 - Price: 4.20 -> 4.40 (+0.2, +4.8%)
- B02 - Price: 3.80 -> 4.00 (+0.2, +5.3%)
- B03 - Price: 4.00 -> 4.20 (+0.2, +5%)
- C02 - Price: 1.90 -> 2.00 (+0.1, +5.3%)
Cinnamon bun removed, Almond croissant added - Cinnamon bun (SKU C03) was removed while Almond croissant (SKU C04) was added at Price 2.40 and Stock 18.
- Removed row C03: SKU C03, Item Cinnamon bun, Price 2.20, Stock 20
- New row C04: SKU C04, Item Almond croissant, Price 2.40, Stock 18, Allergens gluten, nuts
New Allergens column added - Allergens, a new column, was added to the table.
- New column Allergens
Price change for C01 - C01 price went from 1.60 to 16.00, up +14.4 (+900%).
- C01 - Price: 1.60 -> 16.00 (+14.4, +900%) (look again)
Look again (the trusted flags):
- C01 - Price: 1.60 -> 16.00 (+14.4, +900%) - C01 price went from 1.60 to 16.00 (+900%), far larger than other Price changes.
The verdict: check - "Worth a second look"; it follows the flags (1).
The page's check (ReplyKit.check): kept.
No notes.
A weekly rota
The two tables as pasted (before, then after):
Name Mon Tue Wed Thu Fri
Amira 8-4 8-4 off 8-4 8-4
Ben off 12-8 12-8 12-8 off
Chloe 12-8 off 8-4 off 12-8
Dev 8-4 8-4 8-4 off off
Name Mon Tue Wed Thu Fri
Amira 8-4 8-4 off 8-4 8-4
Ben 12-8 12-8 12-8 off off
Chloe off off 8-4 12-8 12-8
Dev 8-4 8-4 8-4 off off
Ella off off 12-8 8-4 8-4
The key the page picks (DiffKit.pickKey): Name. The change lines it sends (DiffKit.line):
c1. Ben | Mon: "off" -> "12-8"
c2. Ben | Thu: "12-8" -> "off"
c3. Chloe | Mon: "12-8" -> "off"
c4. Chloe | Thu: "off" -> "12-8"
c5. added row Ella: Name = Ella; Mon = off; Tue = off; Wed = 12-8; Thu = 8-4; Fri = 8-4
Input:
{
"task": "changes",
"goal": "The cafe's weekly staff rota, this week against next week",
"columns": "Name, Mon, Tue, Wed, Thu, Fri",
"key": "Name",
"counts": "4 rows before, 5 after; 4 changed cells in 2 rows, 1 row added, 0 removed, 0 columns added, 0 removed",
"changes": "c1. Ben | Mon: \"off\" -> \"12-8\"\nc2. Ben | Thu: \"12-8\" -> \"off\"\nc3. Chloe | Mon: \"12-8\" -> \"off\"\nc4. Chloe | Thu: \"off\" -> \"12-8\"\nc5. added row Ella: Name = Ella; Mon = off; Tue = off; Wed = 12-8; Thu = 8-4; Fri = 8-4",
"change_count": "5",
"not_sent": "0"
}
Reply:
{
"task": "changes",
"verdict": "routine",
"summary": "Ben and Chloe swap their Mon and Thu shifts across 4 changed cells in 2 rows, and 1 row is added for Ella.",
"groups": [
{
"title": "Ben and Chloe swap Mon and Thu",
"ids": [
"c1",
"c2",
"c3",
"c4"
],
"why": "Ben's Mon changes from off to 12-8 and Thu from 12-8 to off, while Chloe's go the other way."
},
{
"title": "Ella added to the rota",
"ids": [
"c5"
],
"why": "Ella is added with Wed 12-8, Thu 8-4 and Fri 8-4, and off for Mon and Tue."
}
],
"flags": [],
"note": "Nothing in the Mon and Thu swap for Ben and Chloe or in the new row for Ella looks out of line."
}
The groups the page keeps (ReplyKit.trusted), each change shown with its real values (ReplyKit.plain):
Ben and Chloe swap Mon and Thu - Ben's Mon changes from off to 12-8 and Thu from 12-8 to off, while Chloe's go the other way.
- Ben - Mon: off -> 12-8
- Ben - Thu: 12-8 -> off
- Chloe - Mon: 12-8 -> off
- Chloe - Thu: off -> 12-8
Ella added to the rota - Ella is added with Wed 12-8, Thu 8-4 and Fri 8-4, and off for Mon and Tue.
- New row Ella: Name Ella, Mon off, Tue off, Wed 12-8, Thu 8-4, Fri 8-4
Look again: nothing flagged.
The verdict: routine - "Nothing looks out of line"; it follows the flags (0).
The page's check (ReplyKit.check): kept.
No notes.
A stock count
The two tables as pasted (before, then after):
Code,Product,Shelf,Count
H10,Claw hammer,A1,14
H11,Screwdriver set,A2,9
N20,"Nails, 40mm",B1,320
N21,"Screws, 30mm",B1,410
P30,Paint roller,C3,22
P31,Masking tape,C3,48
Code,Product,Shelf,Count
H10,Claw hammer,A1,11
H11,Screwdriver set,A2,7
N20,"Nails, 40mm",B1,305
N21,"Screws, 30mm",B1,-4
P30,Paint roller,C4,22
The key the page picks (DiffKit.pickKey): Code. The change lines it sends (DiffKit.line):
c1. H10 | Count: "14" -> "11" (-3, -21.4%)
c2. H11 | Count: "9" -> "7" (-2, -22.2%)
c3. N20 | Count: "320" -> "305" (-15, -4.7%)
c4. N21 | Count: "410" -> "-4" (-414, -101%)
c5. P30 | Shelf: "C3" -> "C4"
c6. removed row P31: Code = P31; Product = Masking tape; Shelf = C3; Count = 48
Input:
{
"task": "changes",
"goal": "Month-end stock count for the hardware shop",
"columns": "Code, Product, Shelf, Count",
"key": "Code",
"counts": "6 rows before, 5 after; 5 changed cells in 5 rows, 0 rows added, 1 removed, 0 columns added, 0 removed",
"changes": "c1. H10 | Count: \"14\" -> \"11\" (-3, -21.4%)\nc2. H11 | Count: \"9\" -> \"7\" (-2, -22.2%)\nc3. N20 | Count: \"320\" -> \"305\" (-15, -4.7%)\nc4. N21 | Count: \"410\" -> \"-4\" (-414, -101%)\nc5. P30 | Shelf: \"C3\" -> \"C4\"\nc6. removed row P31: Code = P31; Product = Masking tape; Shelf = C3; Count = 48",
"change_count": "6",
"not_sent": "0"
}
Reply:
{
"task": "changes",
"verdict": "check",
"summary": "5 cells changed across 5 rows, with Count dropping in H10, H11, N20, and N21, and 1 row removed, P31.",
"groups": [
{
"title": "Count drops in H10, H11, N20, N21",
"ids": [
"c1",
"c2",
"c3",
"c4"
],
"why": "Count fell in H10 (14 to 11), H11 (9 to 7), N20 (320 to 305), and N21 (410 to -4)."
},
{
"title": "Shelf change for P30",
"ids": [
"c5"
],
"why": "P30 moved from Shelf C3 to C4."
},
{
"title": "Removed row P31",
"ids": [
"c6"
],
"why": "P31 (Masking tape) was removed from Shelf C3 with a Count of 48."
}
],
"flags": [
{
"id": "c4",
"why": "N21's count went from 410 to -4, a negative count which is not possible for a stock count."
},
{
"id": "c6",
"why": "P31 (Masking tape, Shelf C3, Count 48) was removed, which the month-end stock count does not explain."
}
],
"note": "Check N21, whose count went to -4, and P31, which disappeared from the month-end stock count without explanation."
}
The groups the page keeps (ReplyKit.trusted), each change shown with its real values (ReplyKit.plain):
Count drops in H10, H11, N20, N21 - Count fell in H10 (14 to 11), H11 (9 to 7), N20 (320 to 305), and N21 (410 to -4).
- H10 - Count: 14 -> 11 (-3, -21.4%)
- H11 - Count: 9 -> 7 (-2, -22.2%)
- N20 - Count: 320 -> 305 (-15, -4.7%)
- N21 - Count: 410 -> -4 (-414, -101%) (look again)
Shelf change for P30 - P30 moved from Shelf C3 to C4.
- P30 - Shelf: C3 -> C4
Removed row P31 - P31 (Masking tape) was removed from Shelf C3 with a Count of 48.
- Removed row P31: Code P31, Product Masking tape, Shelf C3, Count 48 (look again)
Look again (the trusted flags):
- N21 - Count: 410 -> -4 (-414, -101%) - N21's count went from 410 to -4, a negative count which is not possible for a stock count.
- Removed row P31: Code P31, Product Masking tape, Shelf C3, Count 48 - P31 (Masking tape, Shelf C3, Count 48) was removed, which the month-end stock count does not explain.
The verdict: check - "Worth a second look"; it follows the flags (2).
The page's check (ReplyKit.check): kept.
No notes.
The change log for the first sample
The first lines of what ReplyKit.csv(form, reply, sent) writes for "A bakery price list" above: every change the page found, with the reply's group and its look-again reason as the last two columns.
id,what,row,column,before,after,change,percent,group,look_again
c1,col-added,,Allergens,,,,,New Allergens column added,
c2,cell,B01,Price,4.20,4.40,0.2,4.8,"Price increased for B01, B02, B03, C02",
c3,cell,B02,Price,3.80,4.00,0.2,5.3,"Price increased for B01, B02, B03, C02",
c4,cell,B03,Price,4.00,4.20,0.2,5,"Price increased for B01, B02, B03, C02",
c5,cell,C01,Price,1.60,16.00,14.4,900,Price change for C01,"C01 price went from 1.60 to 16.00 (+900%), far larger than other Price changes."
Build the input yourself
Load the engine in node (global.window = global; require("./diffkit.js"); require("./replykit.js")), then:
const K = DiffKit, R = ReplyKit;
const form = K.form({ goal: "our price list", key: "",
before: "SKU,Item,Price\nB01,Sourdough loaf,4.20\nC01,Croissant,1.60",
after: "SKU,Item,Price\nB01,Sourdough loaf,4.40\nC01,Croissant,1.60\nC04,Almond croissant,2.40" });
// form.d is the exact difference for free: {key, changes, counts}
const built = K.buildInput(form); // built.ok is false, with built.note, until both tables have rows and something changed
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.sent) // {groups, flags, byId}: only ids the page sent
// t.groups[0].ids.map(id => R.plain(t.byId[id])) // a group's changes with their real values
// R.check(reply, built) // {status: kept or flagged, notes}
// R.csv(form, reply, built.sent) // the change log with group and look_again columns
Form keys: before and after (each a table as CSV, tab- or semicolon-separated text with a header row), goal (what the table is, or empty) and key (the column that identifies a row, or empty to let the page pick).
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope. 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://spot-the-change.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://spot-the-change.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"]
// spot-the-change.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://spot-the-change.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://spot-the-change.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
}
// SpotTheChange.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 SpotTheChange {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://spot-the-change.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://spot-the-change.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://spot-the-change.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://spot-the-change.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":"spot-the-change"}' | 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": "spot-the-change"})["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: "spot-the-change" })).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": "spot-the-change"}, 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", "spot-the-change"), 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: "spot-the-change" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "spot-the-change"])["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 = "spot-the-change" }))!["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": "changes", "goal": "our price list", "columns": "SKU, Item, Price", "key": "SKU", "counts": "2 rows before, 3 after; 1 changed cell in 1 row, 1 row added, 0 removed, 0 columns added, 0 removed", "changes": "c1. B01 | Price: \"4.20\" -> \"4.40\" (+0.2, +4.8%)\nc2. added row C04: SKU = C04; Item = Almond croissant; Price = 2.40", "change_count": "2", "not_sent": "0"}
# - 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": "changes", "goal": "our price list", "columns": "SKU, Item, Price", "key": "SKU", "counts": "2 rows before, 3 after; 1 changed cell in 1 row, 1 row added, 0 removed, 0 columns added, 0 removed", "changes": "c1. B01 | Price: \"4.20\" -> \"4.40\" (+0.2, +4.8%)\nc2. added row C04: SKU = C04; Item = Almond croissant; Price = 2.40", "change_count": "2", "not_sent": "0"}
# - 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: "changes", goal, columns, key, counts, changes, change_count, not_sent} (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 spot-the-change:<task>:<inputHash>:a<attempt> (the one task is changes), 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="spot-the-change:changes:$(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}'
import hashlib, time
key = "spot-the-change:" + 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"), len(reply.get("flags") or []))
import { createHash } from "node:crypto";
const key = `spot-the-change:${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, (reply.flags || []).length);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "spot-the-change:" + 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)
flags, _ := reply["flags"].([]any)
fmt.Println(j["charged_credits"], j["truncated"], reply["verdict"], len(flags))
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "spot-the-change:" + 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") + " " + reply.path("flags").size());
require "digest"
key = "spot-the-change:#{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"], (reply["flags"] || []).size].inspect
$key = "spot-the-change:" . $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"] ?? ""), " ", count($reply["flags"] ?? []), "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"spot-the-change:{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"]} {(reply["flags"] as JsonArray)?.Count ?? 0}");
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: spot-the-change:changes:$(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": "changes", "goal": "our price list", "columns": "SKU, Item, Price", "key": "SKU", "counts": "2 rows before, 3 after; 1 changed cell in 1 row, 1 row added, 0 removed, 0 columns added, 0 removed", "changes": "c1. B01 | Price: \"4.20\" -> \"4.40\" (+0.2, +4.8%)\nc2. added row C04: SKU = C04; Item = Almond croissant; Price = 2.40", "change_count": "2", "not_sent": "0"}
# - 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": "spot-the-change:changes:" + 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": `spot-the-change:changes:${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", "spot-the-change:changes:"+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", "spot-the-change:changes:" + 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" => "spot-the-change:changes:#{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: spot-the-change:changes:" . 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", $"spot-the-change:changes:{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);