Drive Sheet Scrub from your own code
Everything the page does is available over HTTP. The natural use is a nightly job that profiles the export a tool drops in a folder, asks for the steward's decisions only when the profile finds something, and writes the cleaned file plus its change log next to the original.
One thing to be clear about first: the model never sees your whole sheet and never rewrites it. The caller profiles the sheet (types, flags, date evidence, duplicates) and sends that profile with a clipped sample of whole rows. The model returns decisions; the caller applies them. The page's own profiler and applier ship as plain scripts you can load in node — see the end of the examples.
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": "...", "status": 402, "details": { ... } } }
The token is minted for this app (the guest call takes {"slug":"sheet-scrub"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object: never wrap it as {"input": {…}} — that returns 200, warns unknown field 'input', and hides every field from the model.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object. |
| 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: reviews are metered and need a personal token. |
| 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 and the input
The app has one lane. Every run body is a JSON object of four string fields (the declared input schema; task, sheet and facts are required, and /estimate warns when one is missing):
| Field | What it holds |
|---|---|
task | Always "scrub": the steward review of one sheet. Lanes: scrub only. |
context | Optional. What the sheet is and what it is for, in your words (the page cuts the middle past 1,200 characters). |
sheet | CSV of whole rows with the header, prefixed by a _row column holding the spreadsheet row number (header = row 1). The page sends every flagged row first, then the first rows for context, at most 150 rows or 26,000 characters. |
facts | The profile of the WHOLE sheet as a JSON string: sheet (counts, flagged cells by kind, structure warnings), columns[] (type, counts, top values, number stats with outlier fences, date formats and day/month evidence, spelling-variant clusters, same-entity candidates), duplicate_rows, repeated_ids, flags_sample and clipping. |
The reply text is one JSON object:
{
"lane": "scrub", "title": "...", "headline": "...",
"verdict": "ready | fixable | needs_owner | unusable",
"sheet_reading": "...",
"columns": [{ "column", "meaning", "type", "format", "date_order": "YMD|DMY|MDY|n/a|unknown", "missing": "blank|keep|ask", "note" }],
"merges": [{ "column", "to", "from": [...], "confidence": "high|medium|low", "why" }],
"duplicates": [{ "rows": [12, 13], "action": "drop_later|keep_all|ask", "why" }],
"cells": [{ "row", "column", "value", "issue", "action": "set|blank|keep|ask", "to", "why" }],
"questions": [{ "question", "column", "rows": [...] }],
"summary": "...", "notes_on_input": [...], "risks": [...], "next_steps": [...]
}
Worked examples
These are the four bundled examples on the page, one per verdict: the exact run body the page builds and a reply produced against the shipped prompt (the page replays the same replies for free).
fixable: an invoice export merged from two tools
Input:
{
"task": "scrub",
"context": "Invoice export from our ceramics studio for March-April 2026, merged from the old POS (US dates) and the new invoicing tool (ISO dates). The old POS writes refunds in brackets. Going into a revenue-by-customer pivot.",
"sheet": "(a string - the CSV shown below)",
"facts": "(a JSON string; decoded: 31 rows x 8 columns, 34 flagged cells, date 14, space 1, variant 9, numtext 10, outlier 1; columns Invoice No text, Invoice Date date, Customer text, Region text, Item text, Qty number, Amount number, Paid bool)"
}
sheet (whole rows, the sheet row number in _row):
_row,Invoice No,Invoice Date,Customer,Region,Item,Qty,Amount,Paid
2,INV-2041,2026-03-02,Hesperel Lane Cafe,West,Stoneware mugs,24,"$1,104.00",Yes
3,INV-2042,2026-03-03,Jorvane & Oak,east,Serving bowls,6,486.00,Yes
4,INV-2043,03/05/2026,Hesperel Lane Cafe,West,Espresso cups,36,$792.00,yes
5,INV-2044,2026-03-06,Mallowmere Street Bakery,North,Cake stands,4,312.00,No
6,INV-2045,03/09/2026,HESPEREL LANE CAFE,West,Saucers,36,396.00,Yes
7,INV-2046,2026-03-10,Fenhallow Inn,South,Dinner plates,48,"2,208.00",Yes
8,INV-2047,03/12/2026,Jorvane and Oak,East,Pitchers,8,344.00,Y
9,INV-2048,2026-03-13,"Mallowmere Street Bakery ",North,Mixing bowls,5,275.00,Yes
10,INV-2049,03/16/2026,Fenhallow Inn,south,Dinner plates,12,552.00,No
12,INV-2051,03/19/2026,Blue Ostrel Gallery,North,Vases,10,"$1,150.00",Yes
13,INV-2052,2026-03-20,Jorvane & Oak,East,Serving bowls,4,324.00,yes
14,INV-2052,2026-03-20,Jorvane & Oak,East,Serving bowls,4,324.00,yes
15,INV-2053,03/23/2026,Mallowmere Street Bakery,North,Cake stands,2,156.00,Yes
16,INV-2054,2026-03-24,Fenhallow Inn,South,Soup bowls,24,"1,056.00",No
17,INV-2055,03/26/2026,Blue Ostrel Gallery,north,Platters,3,(45.00),Yes
19,INV-2057,03/30/2026,Jorvane & Oak,East,Pitchers,4,172.00,No
21,INV-2059,04/02/2026,Fenhallow Inn,South,Dinner plates,24,"1,104.00",Yes
23,INV-2061,04/06/2026,Hesperel Lane Cafe,West,Saucers,24,264.00,Yes
25,INV-2063,04/09/2026,Mallowmere Street Bakery,North,Cake stands,3,234.00,Yes
26,INV-2064,2026-04-10,Fenhallow Inn,South,Soup bowls,12,528.00,yes
27,INV-2065,04/13/2026,Blue Ostrel Gallery,North,Platters,2,$310.00,No
28,INV-2066,2026-04-14,Hesperel Lane Cafe,West,Stoneware mugs,24,"1,104.00",Yes
29,INV-2067,04/16/2026,Jorvane & Oak,East,Pitchers,6,258.00,Yes
31,INV-2069,04/20/2026,Fenhallow Inn,South,Dinner plates,36,"1,656.00",No
Reply:
{
"lane": "scrub",
"title": "Ceramics Studio Invoices, Mar-Apr 2026",
"headline": "A mostly clean invoice log with one exact duplicate to drop and 14 US-format dates to convert; the owner's note resolves the date order, so nothing is left for them to decide.",
"verdict": "fixable",
"sheet_reading": "Each row is one invoice line from the ceramics studio's till/invoicing export for March-April 2026: an invoice number, date, customer, region, item, quantity, amount and paid status. It is headed into a revenue-by-customer pivot, so customer spelling, duplicate invoices and date order need to be settled first.",
"columns": [
{
"column": "Invoice No",
"meaning": "unique invoice identifier",
"type": "id",
"format": "INV-#### text identifier, kept as text",
"date_order": "n/a",
"missing": "blank",
"note": "30 of 31 values are unique; INV-2052 repeats on rows 13 and 14 as an exact duplicate entry (see duplicates), otherwise no issues."
},
{
"column": "Invoice Date",
"meaning": "date the invoice was written",
"type": "date",
"format": "YYYY-MM-DD (ISO)",
"date_order": "MDY",
"missing": "blank",
"note": "14 cells are written N/N/YYYY (old POS); context states the old POS used US (month-first) dates, and facts confirm 8 of those are unambiguously month-first with 0 certain day-first, so all 14 convert to ISO reading month before day."
},
{
"column": "Customer",
"meaning": "business that placed the order",
"type": "category",
"format": "Title Case business name",
"date_order": "n/a",
"missing": "blank",
"note": "HESPEREL LANE CAFE and Jorvane and Oak are case/punctuation variants merged to their common spellings; row 9's trailing space on Mallowmere Street Bakery trims mechanically; Hesperel Lane Cafe Inc. (seen only in facts, not in the rows shown) is folded in as the same cafe at medium confidence."
},
{
"column": "Region",
"meaning": "sales region",
"type": "category",
"format": "Title Case (East/West/North/South)",
"date_order": "n/a",
"missing": "blank",
"note": "east, north and south are lower-case variants merged to East, North, South."
},
{
"column": "Item",
"meaning": "product sold",
"type": "category",
"format": "Title Case product name",
"date_order": "n/a",
"missing": "blank",
"note": "No flags; 11 distinct item names, consistently spelled."
},
{
"column": "Qty",
"meaning": "units sold on the invoice",
"type": "number",
"format": "plain whole number",
"date_order": "n/a",
"missing": "blank",
"note": "No flags; range 2-48 sits inside the column's own fence (-56 to 84)."
},
{
"column": "Amount",
"meaning": "invoice total, refunds in brackets",
"type": "money",
"format": "plain number, 2 decimals, no currency sign; negative for refunds",
"date_order": "n/a",
"missing": "blank",
"note": "10 cells carry a currency sign, thousands comma or brackets and convert mechanically; row 7's 2,208.00 is a fence outlier but matches the $46/unit rate on rows 21 and 31 for the same item, so it is kept as-is."
},
{
"column": "Paid",
"meaning": "whether the invoice has been paid",
"type": "boolean",
"format": "yes/no",
"date_order": "n/a",
"missing": "blank",
"note": "yes and the abbreviation Y fold into Yes; No has no variant spellings."
}
],
"merges": [
{
"column": "Customer",
"to": "Hesperel Lane Cafe",
"from": [
"HESPEREL LANE CAFE"
],
"confidence": "high",
"why": "case-only variant of the same business (variant_clusters)."
},
{
"column": "Customer",
"to": "Hesperel Lane Cafe",
"from": [
"Hesperel Lane Cafe Inc."
],
"confidence": "medium",
"why": "possible_same_entity grouping; the legal-suffix spelling likely names the same cafe but was not seen in the rows shown, so it is not certain."
},
{
"column": "Customer",
"to": "Jorvane & Oak",
"from": [
"Jorvane and Oak"
],
"confidence": "high",
"why": "\"and\" vs \"&\" spelling of the same business (variant_clusters)."
},
{
"column": "Region",
"to": "East",
"from": [
"east"
],
"confidence": "high",
"why": "case-only variant (variant_clusters)."
},
{
"column": "Region",
"to": "North",
"from": [
"north"
],
"confidence": "high",
"why": "case-only variant (variant_clusters)."
},
{
"column": "Region",
"to": "South",
"from": [
"south"
],
"confidence": "high",
"why": "case-only variant (variant_clusters)."
},
{
"column": "Paid",
"to": "Yes",
"from": [
"yes",
"Y"
],
"confidence": "high",
"why": "case variant and abbreviation are the same affirmative value in this yes/no column."
}
],
"duplicates": [
{
"rows": [
13,
14
],
"action": "drop_later",
"why": "Exact duplicate: same invoice number INV-2052, date, customer, item, qty, amount and paid status (facts.duplicate_rows); the invoice was entered twice."
}
],
"cells": [
{
"row": 7,
"column": "Amount",
"value": "2,208.00",
"issue": "far above the column's middle-fifty range (fences -1,145 to 2,155.50)",
"ac
... (shortened here; the page shows it whole)
needs_owner: a club roster with undecidable dates and a reused member number
Input:
{
"task": "scrub",
"context": "Membership sheet for a cycling club in Ireland, kept by three volunteers on different laptops. Needs to become the insurance return (one row per member, date of birth, category, fee paid).",
"sheet": "(a string - the CSV shown below)",
"facts": "(a JSON string; decoded: 20 rows x 8 columns, 42 flagged cells, date 18, variant 4, missing 2, numtext 18, outlier 1; columns Member # text, Name text, Joined date, Date of birth date, Category text, Annual fee number, Emergency contact text, Waiver signed bool)"
}
sheet (whole rows, the sheet row number in _row):
_row,Member #,Name,Joined,Date of birth,Category,Annual fee,Emergency contact,Waiver signed
2,C-101,Aoife Drumvellow,04/02/2024,12/07/1988,Senior,"€85,00",0871234567,yes
3,C-102,Tomasz Zarnowicze,2024-02-11,03/11/1979,Senior,"€85,00",0869876543,yes
4,C-103,Siobhan Kilvarne,09/03/2024,21/04/2009,Junior,"€40,00",0851112233,Yes
5,C-104,Liam O'Varrigan,09/03/2024,05/06/1992,senior,85,0874445566,yes
6,C-105,Marta Silvarenz,2024-03-16,11/12/1985,Senior,"€85,00",,no
7,C-106,Eoin Walshingrove,01/04/2024,08/09/2010,Junior,"€40,00",0867778899,yes
8,C-107,Grace Murravey,06/04/2024,02/03/1996,Student,"€55,00",0853334455,YES
9,C-108,Niamh Byrnaugh,2024-04-20,17/08/1990,Senior,TBD,0876665544,yes
10,C-109,Daniel Okavendu,05/05/2024,10/10/1983,Senior,"€85,00",0861239876,yes
11,C-110,Hannah Fischlund,11/05/2024,04/01/2008,Junior,"€40,00",n/a,yes
12,C-110,Padraig Doylemore,11/05/2024,30/07/1975,Veteran,"€70,00",0857654321,yes
13,C-112,Chloe Martivelle,2024-06-01,06/05/1999,Student,"€55,00",0879871234,no
14,C-113,Ruairi Gallowmere,08/06/2024,09/02/2007,Junior,"40,00",0864561237,yes
15,C-114,Yuki Tanavuro,07/07/2024,01/08/1987,Senior,"€85,00",0852223344,yes
16,C-115,Sean Bradwhistle,2024-07-13,12/12/1968,veteran,"€70,00",0873332211,yes
17,C-116,Orla Quintravel,03/08/2024,07/03/2011,Junior,"€40,00",0868889900,
18,C-117,Mateo Garcivell,10/08/2024,02/02/1994,Senior,"€85,00",0855556677,yes
19,C-118,Aisling Ryvanagh,2024-08-24,04/11/2001,Student,"€55,00",0871110099,yes
20,C-119,Kevin Dohervane,07/09/2024,09/06/1980,Senior,"€850,00",0864443322,yes
21,C-120,Aoife Drumvellow,04/02/2024,12/07/1988,Senior,"€85,00",0871234567,yes
Reply:
{
"lane": "scrub",
"title": "Cycling Club Roster for Insurance Return",
"headline": "Mostly mechanical cleanup, but a reused Member #, a likely duplicate member, a missing fee and a blank waiver need the owner before this becomes the insurance return.",
"verdict": "needs_owner",
"sheet_reading": "Each row is one club member's registration: join date, date of birth, membership category, annual fee, emergency contact and waiver status, kept by three volunteers on separate laptops. It is about to be reshaped into the annual insurance return, which needs one accurate row per member with category, date of birth and fee paid.",
"columns": [
{
"column": "Member #",
"meaning": "the member's club identifier",
"type": "id",
"format": "as written, e.g. C-101",
"date_order": "n/a",
"missing": "blank",
"note": "C-110 is shared by two different people (rows 11-12) and C-101/C-120 look like the same person entered twice (rows 2 and 21) - see duplicates; otherwise all 20 values are distinct codes."
},
{
"column": "Name",
"meaning": "the member's full name",
"type": "text",
"format": "Title Case, as written",
"date_order": "n/a",
"missing": "blank",
"note": "No flags; row 21's Aoife Drumvellow repeats row 2's exactly under a different Member # - see duplicates."
},
{
"column": "Joined",
"meaning": "date the member joined the club",
"type": "date",
"format": "YYYY-MM-DD",
"date_order": "DMY",
"missing": "blank",
"note": "14 of 20 values are N/N/YYYY with day and month both <=12 (ambiguous on their own) and 6 are already ISO. Read as day-first using the Date of birth column's proven order (3 certain day-first values in this same sheet) plus the club's Irish context, then rewritten to ISO."
},
{
"column": "Date of birth",
"meaning": "the member's date of birth",
"type": "date",
"format": "YYYY-MM-DD",
"date_order": "DMY",
"missing": "blank",
"note": "All N/N/YYYY; rows 4, 9 and 12 have a day over 12, proving the column is day-first, so every value - including the ambiguous ones - converts to ISO as DMY."
},
{
"column": "Category",
"meaning": "membership category",
"type": "category",
"format": "Title Case",
"date_order": "n/a",
"missing": "blank",
"note": "senior (row 5) and veteran (row 16) are case variants of the majority spellings Senior and Veteran - both merged."
},
{
"column": "Annual fee",
"meaning": "annual membership fee paid, in euro",
"type": "money",
"format": "plain number, 2 decimals, no currency sign (decimal comma read as decimal point)",
"date_order": "n/a",
"missing": "ask",
"note": "Stored as text with a euro sign and decimal comma (e.g. €85,00 = 85.00). Row 9 is \"TBD\" and goes to the owner. Row 20's €850,00 is an extra zero on the standard Senior fee that every other Senior member pays (€85,00) and is set to 85.00."
},
{
"column": "Emergency contact",
"meaning": "phone number to call in an emergency",
"type": "text",
"format": "digits only, leading zero kept",
"date_order": "n/a",
"missing": "blank",
"note": "Row 6 is blank and row 11's \"n/a\" is a placeholder; both become blank. Not required for the insurance return, but two members end up with no contact on file."
},
{
"column": "Waiver signed",
"meaning": "whether the liability waiver was signed",
"type": "boolean",
"format": "yes/no, lowercase",
"date_order": "n/a",
"missing": "blank",
"note": "YES (row 8) and Yes (row 4) fold into the majority spelling yes. Row 17 is genuinely blank, not a placeholder - the owner must confirm Orla Quintravel's waiver status before this is used as a liability/insurance record."
}
],
"merges": [
{
"column": "Category",
"to": "Senior",
"from": [
"senior"
],
"confidence": "high",
"why": "Only case differs from the majority spelling Senior; same membership category."
},
{
"column": "Category",
"to": "Veteran",
"from": [
"veteran"
],
"confidence": "high",
"why": "Only case differs from the majority spelling Veteran; same membership category."
},
{
"column": "Waiver signed",
"to": "yes",
"from": [
"YES",
"Yes"
],
"confidence": "high",
"why": "YES and Yes are case variants of the majority spelling yes; same affirmative answer."
}
],
"duplicates": [
{
"rows": [
11,
12
],
"action": "ask",
"why": "Member # C-110 is shared by Hannah Fischlund (Junior, b. 04/01/2008) and Padraig Doylemore (Veteran, b. 30/07/1975) - different people, so this is a conflicting identifier, not a duplicate; the owner must assign a correct number to one of them."
},
{
"rows": [
2,
21
],
"action": "ask",
"why": "Row 21 (C-120, Aoife Drumvellow) matches row 2 (C-101, Aoife Drumvellow) exactly on name, joined date, date of birth, category, fee and phone - likely the same person entered twice under two Member #s; the owner should confirm which number is correct and whether to drop one row."
}
],
"cells": [
{
"row": 9,
"column": "Annual fee",
"value": "TBD",
"issue": "placeholder standing in for the ac
... (shortened here; the page shows it whole)
ready: a household meter log with one stray space
Input:
{
"task": "scrub",
"context": "Our monthly electricity meter readings for the last year, typed off the meter on the first of each month. Want to chart usage by month.",
"sheet": "(a string - the CSV shown below)",
"facts": "(a JSON string; decoded: 12 rows x 6 columns, 1 flagged cells, space 1; columns Reading date date, Meter reading (kWh) number, Usage since last (kWh) number, Days number, Tariff (c/kWh) number, Estimated? bool)"
}
sheet (whole rows, the sheet row number in _row):
_row,Reading date,Meter reading (kWh),Usage since last (kWh),Days,Tariff (c/kWh),Estimated?
2,2025-10-01,48211,,,,No
3,2025-11-01,48529,318,31,32.4,No
4,2025-12-01,48902,373,30,32.4,No
5,2026-01-01,49327,425,31,32.4,No
6,2026-02-01,49751,424,31,33.1,Yes
7,2026-03-01,50118,367,28,33.1,No
8,2026-04-01,50441,323,31,33.1,No
9,2026-05-01,50709,268,30,33.1," No"
Reply:
{
"lane": "scrub",
"title": "Monthly Electricity Meter Readings",
"headline": "A clean 12-month meter log with one stray space to trim and nothing left for the owner to decide.",
"verdict": "ready",
"sheet_reading": "Each row is one monthly meter reading, taken on the first of the month, with the cumulative meter value, the usage and days since the prior reading, the tariff in effect, and whether the reading was estimated. It is about to be charted as monthly electricity usage.",
"columns": [
{
"column": "Reading date",
"meaning": "the calendar date the meter was read",
"type": "date",
"format": "YYYY-MM-DD",
"date_order": "YMD",
"missing": "blank",
"note": "All 12 dates are written YYYY-MM-DD with zero ambiguous or minority-format entries (certain_dmy=0, certain_mdy=0, ambiguous=0), so the order is YMD directly from the format, not inferred."
},
{
"column": "Meter reading (kWh)",
"meaning": "the cumulative meter dial value at the time of reading",
"type": "number",
"format": "plain integer, kWh",
"date_order": "n/a",
"missing": "blank",
"note": "Strictly increasing across rows and the row-to-row difference matches 'Usage since last' exactly on every visible row (e.g. 48529-48211=318); all values sit inside the fences (43883-56337.75), no outliers."
},
{
"column": "Usage since last (kWh)",
"meaning": "kWh consumed since the previous reading",
"type": "number",
"format": "plain integer, kWh",
"date_order": "n/a",
"missing": "blank",
"note": "Row 2 is blank by design: it is the first reading, so there is no prior reading to subtract from. Not a placeholder, left alone. All other values are within the fences (-174 to 778)."
},
{
"column": "Days",
"meaning": "number of days since the previous reading",
"type": "number",
"format": "plain integer, whole days",
"date_order": "n/a",
"missing": "blank",
"note": "Row 2 is blank for the same reason as Usage (no prior reading). The three distinct values (28, 30, 31) are all plausible month lengths and within the fences (27-34)."
},
{
"column": "Tariff (c/kWh)",
"meaning": "electricity price in effect for that billing period, cents per kWh",
"type": "number",
"format": "plain number, 1 decimal, cents/kWh",
"date_order": "n/a",
"missing": "blank",
"note": "A rate, not a cash amount, so kept as number rather than money. Row 2 is blank (no usage yet to price). The three rates seen across the full sheet (31.8, 32.4, 33.1) are all within the fences (27.9-37), consistent with normal tariff changes over a year."
},
{
"column": "Estimated?",
"meaning": "whether the reading was estimated rather than read off the meter",
"type": "boolean",
"format": "Yes/No",
"date_order": "n/a",
"missing": "blank",
"note": "Row 9 is flagged for a leading space (' No') - whitespace only, trimmed mechanically by the browser. Only one spelling each of Yes and No appears, so no merge is needed."
}
],
"merges": [],
"duplicates": [],
"cells": [],
"questions": [],
"summary": "Twelve months of meter readings (Oct 2025-Sep 2026), one row per month, with the cumulative reading, derived usage, days, tariff and an estimated flag. The only issue found is a stray leading space on one 'Estimated?' cell, which the browser trims automatically. Meter readings, usage and days are internally consistent across every row shown, with no outliers, duplicates or ambiguous dates - the sheet is ready to chart as-is.",
"notes_on_input": [
"Only 8 of the 12 profiled rows were sent (clipping note: 12 rows profiled, 8 sent); the 4 rows not shown account for lower usage values (224-235 kWh) and a 31.8 c/kWh tariff that appear in facts.columns top_values/stats but not in the visible sheet text - expected clipping, not a contradiction."
],
"risks": [
"Row 2's blank Usage/Days/Tariff cells must be charted as 'no data' rather than coerced to zero, or the first month will show a false usage drop to zero.",
"The rows not shown here were not individually checked for internal consistency (reading-minus-prior-reading vs. stated usage); if one of them breaks the pattern, this verdict would need revisiting once the owner reviews the full sheet."
],
"next_steps": [
"Trim the leading space on row 9's 'Estimated?' value (mechanical, browser handles it).",
"Leave row 2 out of any month-over-month usage chart since it has no prior reading to derive usage from.",
"Chart 'Usage since last (kWh)' against 'Reading date' for the monthly trend requested in context.",
"If the full sheet is available, spot-check the rows beyond this clip for the same reading-minus-prior-reading consistency confirmed here."
]
}
unusable: a bank statement with its account preamble above the header
Input:
{
"task": "scrub",
"context": "My checking account's August statement, downloaded as CSV from the bank site. I want to categorise spending in a budget sheet.",
"sheet": "(a string - the CSV shown below)",
"facts": "(a JSON string; decoded: 17 rows x 2 columns, 0 flagged cells, no flags; columns Account name mixed, J. Vantorre Everyday Checking text)"
}
sheet (whole rows, the sheet row number in _row):
_row,Account name,J. Vantorre Everyday Checking
2,Account number,****4417
3,Statement period,2026-08-01 to 2026-08-31
4,Date,Description
5,2026-08-01,Opening balance
6,2026-08-02,GROCER 0231 SPRINGFIELD
7,2026-08-04,PAYROLL ACME TOOLING
8,2026-08-05,RENT AUG
9,2026-08-09,CITY POWER AUTOPAY
10,2026-08-12,COFFEE CORNER
11,2026-08-15,TRANSFER TO SAVINGS
12,2026-08-18,PAYROLL ACME TOOLING
13,2026-08-22,PHARMACY 118
14,2026-08-29,MOBILE PLAN
15,Summary,
Reply:
{
"lane": "scrub",
"title": "August checking statement (wrong header row)",
"headline": "This is a raw bank-statement export whose real transaction header (Date, Description, Debit, Credit, Balance) sits on file line 5 behind 3 metadata lines, so the parser used line 1 as the header and dropped every Debit/Credit/Balance value - re-export from the real header line before this can be scrubbed.",
"verdict": "unusable",
"sheet_reading": "This file is a checking-account statement download (J. Vantorre Everyday Checking) where each real row should be one transaction with a date, description, debit, credit and running balance, but the first 3 lines are account-name/number/period metadata and the true header is on file line 5. Because the browser parsed line 1 as the header, only the first two fields of each row (a date-like label and a description) came through here; the Debit, Credit and Balance values the owner needs to categorise spending are not present in this read. facts.columns top_values also show figures like 1775.94 and 3780.00 that never appear in the rows shown, consistent with a summary footer in the rows that were profiled but not sent.",
"columns": [
{
"column": "Account name",
"meaning": "Meant to be the transaction date for each row, but because the parser used file line 1 as the header, this column actually mixes the preamble labels ('Account number', 'Statement period'), the literal word 'Date' (the real header, misread as a data row), the transaction dates, and the word 'Summary'.",
"type": "mixed",
"format": "n/a - column conflates labels, the literal header word, and ISO dates because of the misread header",
"date_order": "n/a",
"missing": "blank",
"note": "Fully filled (17/17), no placeholders. The embedded transaction dates (rows 5-14) are already ISO YYYY-MM-DD, but the column mixes labels, the header word and dates, so no single type or date_order applies until the file is re-exported starting at the real header line (file line 5)."
},
{
"column": "J. Vantorre Everyday Checking",
"meaning": "Meant to be the transaction description for each row, but actually mixes the account's display name, masked account number, statement-period string, the literal word 'Description', the transaction descriptions, and - per facts.columns top_values ('1775.94', '3780.00') - apparent summary totals from rows beyond this view.",
"type": "text",
"format": "free text (mixture of label values and transaction descriptions)",
"date_order": "n/a",
"missing": "blank",
"note": "One blank (row 15, the 'Summary' row) is blank by design, not a placeholder - no 'keep' needed. 'PAYROLL ACME TOOLING' repeats on rows 7 and 12 (two different dates, two separate pay events, not a duplicate). Real content and type can't be settled until the file is re-exported from its real header line."
}
],
"merges": [],
"duplicates": [],
"cells": [],
"questions": [
{
"question": "Can you re-export this statement starting at the real header line (Date, Description, Debit, Credit, Balance), or paste just the transaction rows without the account-name/account-number/statement-period preamble above it?",
"column": "",
"rows": [
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15
]
}
],
"summary": "This CSV is a raw bank-statement export, not a flat transactions table: lines 1-3 are account metadata and the real column header (Date, Description, Debit, Credit, Balance) only appears on file line 5. Because the profiler used line 1 as the header, every transaction's Debit, Credit and Balance amounts were dropped from this read - only dates and descriptions survived, which blocks the stated goal of categorising spending by amount. No merges, duplicates or placeholder decisions could be evaluated because the real data columns aren't present yet. Re-export starting at the real header row (or delete the first 4 lines) and re-run scrub.",
"notes_on_input": [
"facts.sheet.warnings already names this exact problem: header_width is 2 but common_width is 5, and the real header looks like file line 5 ('Date, Description, Debit, Credit, Balance') - the lines above it should be deleted or the file re-exported.",
"facts.sheet.rows says 17 rows were profiled but clipping.rows_sent is 14 (rows 2-15 shown here); 3 further rows were profiled but not sent, and facts.columns[1].top_values lists values ('1775.94', '3780.00') that never appear in the 14 rows shown, so those hidden rows likely hold summary totals.",
"task field was 'scrub' as expected, no mismatch to report there."
],
"risks": [
"If this sheet were cleaned and used as-is, every transaction would have a date and description but no dollar amount, so categorising spending by amount is impossible.",
"The 3 unseen trailing rows (profiled but not sent) may contain further transactions or only a summary footer; that is not yet confirmed from this view.",
"The two 'PAYROLL ACME TOOLING' rows (file rows 7 and 12) look like two separate pay dates rather than a duplicate, but this can't be fully confirmed without the Debit/Credit columns to compare amounts."
],
"
... (shortened here; the page shows it whole)
The page re-checks the reply and applies it; the API does not
Over the API you get the steward's decisions as JSON. The page goes further, and you can too with the same plain scripts: scrub.js (Scrub.analyze(text) for the profile, Scrub.buildInput(facts, context) for this exact run body) and plan.js (Plan.normalize(reply, header), Plan.reconcile(result, facts) for the disagreements - a column with flagged cells and no decision, a date order the unambiguous dates contradict, a merge naming a spelling that is not in the column, a misquoted cell, an undecided duplicate group - and Plan.apply(facts, result) for the cleaned rows, the change log and the open items). Both load in node after global.window = {}.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://sheet-scrub.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://sheet-scrub.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"]
// sheet-scrub.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://sheet-scrub.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://sheet-scrub.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
}
// SheetScrub.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 SheetScrub {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://sheet-scrub.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://sheet-scrub.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://sheet-scrub.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://sheet-scrub.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":"sheet-scrub"}' | 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": "sheet-scrub"})["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: "sheet-scrub" })).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": "sheet-scrub"}, 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", "sheet-scrub"), 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: "sheet-scrub" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "sheet-scrub"])["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 = "sheet-scrub" }))!["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 (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.
# input.json: the run input object - {"task": "scrub", "context": "...", "sheet": "...", "facts": "..."}
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": "scrub", "context": "...", "sheet": "...", "facts": "..."}
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, context, sheet, facts}
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 sheet-scrub:scrub:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="sheet-scrub:scrub:$(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 lane's contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "sheet-scrub:" + 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 lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("headline"))
import { createHash } from "node:crypto";
const key = `sheet-scrub:${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.headline);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "sheet-scrub:" + 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 }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "sheet-scrub:" + 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);
require "digest"
key = "sheet-scrub:#{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["headline"]].inspect
$key = "sheet-scrub:" . $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["headline"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"sheet-scrub:{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}");
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: sheet-scrub:scrub:$(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": "scrub", "context": "...", "sheet": "...", "facts": "..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "sheet-scrub:scrub:" + 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": `sheet-scrub:scrub:${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", "sheet-scrub:scrub:"+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", "sheet-scrub:scrub:" + 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" => "sheet-scrub:scrub:#{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: sheet-scrub:scrub:" . 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", $"sheet-scrub:scrub:{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);