← Tidy Names
API

Drive Tidy Names from your own code

The one metered task, names, is available over HTTP. It takes a folder of file names as the page has read them - what the files are and the parts the person wants in each name, every file as one numbered line (f1, f2 ...) with the name as typed, the date the page found in it (marked when it is a day-first guess), its version tag and its extension, and the counts - and returns one naming scheme (a pattern every new name follows, with a reason), a new name for each file whose every part can be read from its own name or the description, a question for each file it cannot place, a verdict (consistent or partial) and a note.

The division of labour first: the page reads every name, applies the person's own rules, checks every new name and writes the scripts; the model proposes the scheme and the names, naming files only by id so the page can tie each new name to one real file and check it against that file's old name. The page reads up to 300 names (a repeated line is read once), sends the first 60, and flags any new name over 120 characters. After the reply it keeps one new name per sent file, re-checks every name - collisions ignoring case, the extension, characters Windows refuses, reserved names, and any date, number or word the old name and the description do not carry - checks the verdict against the questions, and writes the rename and undo scripts and the old-new CSV. Nothing is renamed on the page. Your code can do the same with the engine files the page loads (see "Build the input yourself").

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"tidy-names"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field (task, goal, scheme, files, counts) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: the names run is metered and needs a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field, the input and the rules

Every run sends task: names (the one task is names); a missing or different one is still answered as names. All fields are strings. What each holds, in the words of the system prompt, with the first sample's value (its first line where it has several):

FieldRequiredWhat it holds
taskyesAlways names. If it is missing or different, still answer this task and set "task": "names".
goalyesWhat the files are, in the person's words, or none. First sample: Invoices I sent to clients this year.
schemeyesThe parts the person wants in each name, in order, or none. First sample: date, client, invoice.
filesyesOne line per file: f1. name | date: YYYY-MM-DD, YYYY-MM or none (day-first guess when the date could be read either way) | version | extension. First sample: f1. Invoice_ACME_march2024.pdf | date: 2024-03 | version: none | extension: .pdf ...
countsyesHow many files, how many carry a date, how many kinds of extension. First sample: 7 files; 6 with a date; 1 kind of extension.
retry_notenoOnly on a reformat retry: why the previous reply was unusable.

How the page reads the names (NameKit.form): one name per line, a leading bullet or number dropped, folders allowed (2024/receipts/scan.pdf); up to 300 lines, a line repeated ignoring case read once. Each name is split into folder, stem and extension (up to five letters or digits after the last dot; .tar.gz, .tar.bz2 and .tar.xz stay together; a leading dot is a hidden file with no extension). The first date in the stem is found in any of these forms: 2024-03-05, 2024_03_05 or 2024.03.05, 20240305, 2024-3-5, 05.03.2024 (day first; when both numbers are 12 or under and differ, it is marked a day-first guess), 5 Mar 2024, March 5 2024, March 2024 and 2024-03. A version tag is v, ver, version or rev followed by a number (v3, ver 2, rev 1.2). The page needs at least one name.

The free rules (NameKit.ruleNames), in order: find and replace; drop copy marks (Copy of, (1), copy, final); dates to YYYY-MM-DD where they are, moved to the front, or kept; one separator (hyphen, underscore, space, or as typed); one case (lower, Title, UPPER, or as typed); a prefix; numbering first or last; extensions in lower case. Every new name is checked (NameKit.check(form, names), one row per file with status ok, same or bad and its issues): an empty name, a character Windows refuses (< > : " / \ | ? * and control characters), a trailing space or dot, a reserved Windows name (CON, PRN, AUX, NUL, COM1-COM9, LPT1-LPT9), a lost or changed extension, a name over 120 characters, and a collision with another file's new name ignoring case, as Drive, macOS and Windows treat names.

The model's rules

As the system prompt states them, in order:

  1. scheme - pattern: the shape every new name follows, at most 60 characters, e.g. parts joined by one separator with the extension at the end, written with the words people would read (date as YYYY-MM-DD). Follow the order in scheme when one is given; otherwise put the date first when most files carry one. why: one sentence, at most 25 words.
  2. names - one entry for each file whose every part of the pattern can be read from its own name or from goal, in id order: file (its id), name (the new name, extension included, no folder) and why (at most 12 words when the change is not obvious from the old name - say a date read day-first - else ""). In every new name:
    • keep the file's extension, in lower case;
    • use only lower-case letters, digits, hyphens and underscores, plus the dot before the extension;
    • write dates as YYYY-MM-DD, or YYYY-MM when the old name gives only a month; take a date only from the file's own date, or from goal when it gives one date for every file;
    • take words only from the file's own name, goal or scheme - an abbreviation may be written out (inv as invoice) - and numbers only from the file's own name;
    • keep a version number the old name has, and drop words that only mark a duplicate (copy of, copy, (1));
    • keep a word that says which state a file is in (draft, final, old, with comments) when the folder holds other versions of the same document, since it is what tells them apart; drop it when no other file shares the name;
    • no two files may end up with the same name; when they would, keep a part from the old names that tells them apart.
  3. unsure - each file left out of names because a part the pattern needs is missing or doubtful (no date, no client, a date that reads two ways with nothing to settle it): file and question - one sentence, at most 20 words, asking the person for the missing part.
  4. verdict - consistent when unsure is empty; partial otherwise.
  5. note - one sentence, at most 30 words: what the scheme does to the folder, and what the person must answer for the files left out.

And the house rules:

The reply

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

{
  "task": "names",
  "verdict": "consistent | partial",
  "scheme": { "pattern": "the shape of every name", "why": "one sentence" },
  "names": [ { "file": "f1", "name": "new-name.ext", "why": "a few words or empty" } ],
  "unsure": [ { "file": "f2", "question": "one sentence" } ],
  "note": "one sentence"
}

The page reads the reply with ReplyKit.normalize (the verdict and the file ids matched in any case) and keeps what it trusts with ReplyKit.trusted(reply, built): a name is set aside, with the reason, when it names a file the page did not send, a file that already has a new name, when it is empty or when it holds a slash and so would move the file to another folder; a question is kept for each sent file it names; and every sent file with neither a new name nor a question is listed as missing and keeps its name. The trusted names go through NameKit.check again, so t.rows carries a status and the issues for every file. The old names, the folders and the extensions always come from the page's own reading, never from the reply.

ReplyKit.check(reply, built) lists, under "The page's check": a missing verdict, or one that the questions contradict (partial exactly when a file is left with a question); no pattern, or one over 60 characters; every name set aside and every file with neither a name nor a question; a file both renamed and asked about (the page uses the name), and a question left empty; every issue NameKit.check finds in a kept name; a name with characters beyond lower-case letters, digits, hyphens and underscores; a date the old name and the description do not carry, or a dropped date the old name does carry; a number the old name and the description do not have; a word the old name, the description and the scheme do not have (an abbreviation written out and a version tag are fine); new names that mix separators; a sentence that names a file id, an internal field or uses backticks; and words such as "again", "already", "always", "never" or "every" that the input never used. Any warning makes the status flagged; otherwise it is kept. The verdict on the page reads "Every file fits the scheme" or "Some files need your answer".

The exports: NameKit.bash(rows, undo) and NameKit.powershell(rows, undo) write a rename script (or, with undo true, the script that puts every name back) for the rows whose status is ok, in two phases - every file first moves to a temporary .tidy-names-N.tmp name, then to its new name - so a chain of renames can never overwrite a file; the bash script uses mv -n and stops on the first error, the PowerShell script uses Move-Item -LiteralPath with CRLF line ends. NameKit.toCSV(rows) writes one row per file (old, new, status, issues) with CRLF line ends; ReplyKit.md and ReplyKit.json write the scheme, every old and new name and the questions, and the label drawer saves as PNG or SVG.

Worked examples - every sample

These are the free samples on the page: the file names as typed, the names as the page reads them, what the page's own rules make of them, the exact input it sends, the reply that replays for free, and what the page does with it - the renames it trusts, old against new with a status each, the questions, the verdict against the questions and its own check. Together they cover the verdicts partial and consistent.

A year of invoices

The form: the files are Invoices I sent to clients this year; the parts wanted, in order: date, client, invoice. The rule settings: no find and replace, dates iso, copy marks drop, separator -, case lower, extension case lower, no prefix, numbering none. The file names as typed:

Invoice_ACME_march2024.pdf
acme invoice 2024-04-02 FINAL.pdf
INV-Bluebird-03.05.2024.pdf
Copy of Invoice Bluebird 14 June 2024.pdf
invoice_acme (1).pdf
Northwind inv 12 Jan 2024.PDF
northwind_invoice_2024_02_09.pdf

The names as the page reads them (NameKit.form): the first date in each, its version tag and its extension.

FileNameDateVersionExtension
f1Invoice_ACME_march2024.pdf2024-03none.pdf
f2acme invoice 2024-04-02 FINAL.pdf2024-04-02none.pdf
f3INV-Bluebird-03.05.2024.pdf2024-05-03 (day-first guess)none.pdf
f4Copy of Invoice Bluebird 14 June 2024.pdf2024-06-14none.pdf
f5invoice_acme (1).pdfnonenone.pdf
f6Northwind inv 12 Jan 2024.PDF2024-01-12none.PDF
f7northwind_invoice_2024_02_09.pdf2024-02-09none.pdf

The page warns: "1 date reads day-first (05.03.2024 is 5 March); check them."

The page's own renames by those rules, free and before any run (NameKit.check(f, NameKit.ruleNames(f))):

Old nameNew nameStatusIssues
Invoice_ACME_march2024.pdfinvoice-acme-2024-03.pdfrename-
acme invoice 2024-04-02 FINAL.pdfacme-invoice-2024-04-02.pdfrename-
INV-Bluebird-03.05.2024.pdfinv-bluebird-2024-05-03.pdfrename-
Copy of Invoice Bluebird 14 June 2024.pdfinvoice-bluebird-2024-06-14.pdfrename-
invoice_acme (1).pdfinvoice-acme.pdfrename-
Northwind inv 12 Jan 2024.PDFnorthwind-inv-2024-01-12.pdfrename-
northwind_invoice_2024_02_09.pdfnorthwind-invoice-2024-02-09.pdfrename-

Input:

{
 "task": "names",
 "goal": "Invoices I sent to clients this year",
 "scheme": "date, client, invoice",
 "files": "f1. Invoice_ACME_march2024.pdf | date: 2024-03 | version: none | extension: .pdf\nf2. acme invoice 2024-04-02 FINAL.pdf | date: 2024-04-02 | version: none | extension: .pdf\nf3. INV-Bluebird-03.05.2024.pdf | date: 2024-05-03 (day-first guess) | version: none | extension: .pdf\nf4. Copy of Invoice Bluebird 14 June 2024.pdf | date: 2024-06-14 | version: none | extension: .pdf\nf5. invoice_acme (1).pdf | date: none | version: none | extension: .pdf\nf6. Northwind inv 12 Jan 2024.PDF | date: 2024-01-12 | version: none | extension: .PDF\nf7. northwind_invoice_2024_02_09.pdf | date: 2024-02-09 | version: none | extension: .pdf",
 "counts": "7 files; 6 with a date; 1 kind of extension"
}

Reply:

{
 "task": "names",
 "verdict": "partial",
 "scheme": {
  "pattern": "YYYY-MM-DD-client-invoice.ext",
  "why": "This follows the order requested: date first, then client, then invoice."
 },
 "names": [
  {
   "file": "f1",
   "name": "2024-03-acme-invoice.pdf",
   "why": ""
  },
  {
   "file": "f2",
   "name": "2024-04-02-acme-invoice.pdf",
   "why": "dropped final, no other version of this invoice"
  },
  {
   "file": "f4",
   "name": "2024-06-14-bluebird-invoice.pdf",
   "why": "dropped duplicate marker copy of"
  },
  {
   "file": "f6",
   "name": "2024-01-12-northwind-invoice.pdf",
   "why": ""
  },
  {
   "file": "f7",
   "name": "2024-02-09-northwind-invoice.pdf",
   "why": ""
  }
 ],
 "unsure": [
  {
   "file": "f3",
   "question": "For INV-Bluebird-03.05.2024.pdf, is the date 3 May 2024 or 5 March 2024?"
  },
  {
   "file": "f5",
   "question": "What date should be used for invoice_acme (1).pdf, since its name has no date?"
  }
 ],
 "note": "Each invoice gets a name built from its date, client and the word invoice; please confirm the date for invoice_acme (1).pdf and whether INV-Bluebird-03.05.2024.pdf's date is day-first or month-first."
}

The scheme: YYYY-MM-DD-client-invoice.ext - "This follows the order requested: date first, then client, then invoice."

The renames the page trusts (ReplyKit.trusted, every name checked again), old against new; a file with no new name keeps its own:

Old nameNew nameStatusIssuesWhy
Invoice_ACME_march2024.pdf2024-03-acme-invoice.pdfrename--
acme invoice 2024-04-02 FINAL.pdf2024-04-02-acme-invoice.pdfrename-dropped final, no other version of this invoice
INV-Bluebird-03.05.2024.pdfINV-Bluebird-03.05.2024.pdfunchanged, asked below--
Copy of Invoice Bluebird 14 June 2024.pdf2024-06-14-bluebird-invoice.pdfrename-dropped duplicate marker copy of
invoice_acme (1).pdfinvoice_acme (1).pdfunchanged, asked below--
Northwind inv 12 Jan 2024.PDF2024-01-12-northwind-invoice.pdfrename--
northwind_invoice_2024_02_09.pdf2024-02-09-northwind-invoice.pdfrename--

The questions:

The verdict: partial - "Some files need your answer"; it agrees with the questions: 2 files are left with a question.

The note: "Each invoice gets a name built from its date, client and the word invoice; please confirm the date for invoice_acme (1).pdf and whether INV-Bluebird-03.05.2024.pdf's date is day-first or month-first."

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

No notes.

Wedding photos from two cameras

The form: the files are Photos from Maya and Leo's wedding on 15 June 2024, from my camera and my phone; the parts wanted, in order: date, event, camera, number. The rule settings: no find and replace, dates iso, copy marks drop, separator -, case lower, extension case lower, no prefix, numbering none. The file names as typed:

IMG_20240615_143201.jpg
IMG_20240615_143455.jpg
IMG_20240615_170512.JPG
DSC00412.ARW
DSC00413.ARW
DSC00419.ARW
PXL_20240615_181045123.jpg

The names as the page reads them (NameKit.form): the first date in each, its version tag and its extension.

FileNameDateVersionExtension
f1IMG_20240615_143201.jpg2024-06-15none.jpg
f2IMG_20240615_143455.jpg2024-06-15none.jpg
f3IMG_20240615_170512.JPG2024-06-15none.JPG
f4DSC00412.ARWnonenone.ARW
f5DSC00413.ARWnonenone.ARW
f6DSC00419.ARWnonenone.ARW
f7PXL_20240615_181045123.jpg2024-06-15none.jpg

The page's own renames by those rules, free and before any run (NameKit.check(f, NameKit.ruleNames(f))):

Old nameNew nameStatusIssues
IMG_20240615_143201.jpgimg-2024-06-15-143201.jpgrename-
IMG_20240615_143455.jpgimg-2024-06-15-143455.jpgrename-
IMG_20240615_170512.JPGimg-2024-06-15-170512.jpgrename-
DSC00412.ARWdsc00412.arwrename-
DSC00413.ARWdsc00413.arwrename-
DSC00419.ARWdsc00419.arwrename-
PXL_20240615_181045123.jpgpxl-2024-06-15-181045123.jpgrename-

Input:

{
 "task": "names",
 "goal": "Photos from Maya and Leo's wedding on 15 June 2024, from my camera and my phone",
 "scheme": "date, event, camera, number",
 "files": "f1. IMG_20240615_143201.jpg | date: 2024-06-15 | version: none | extension: .jpg\nf2. IMG_20240615_143455.jpg | date: 2024-06-15 | version: none | extension: .jpg\nf3. IMG_20240615_170512.JPG | date: 2024-06-15 | version: none | extension: .JPG\nf4. DSC00412.ARW | date: none | version: none | extension: .ARW\nf5. DSC00413.ARW | date: none | version: none | extension: .ARW\nf6. DSC00419.ARW | date: none | version: none | extension: .ARW\nf7. PXL_20240615_181045123.jpg | date: 2024-06-15 | version: none | extension: .jpg",
 "counts": "7 files; 4 with a date; 2 kinds of extension"
}

Reply:

{
 "task": "names",
 "verdict": "consistent",
 "scheme": {
  "pattern": "YYYY-MM-DD-event-camera-number.ext",
  "why": "The date, which device took the photo, and the number in its name set the wedding photos apart."
 },
 "names": [
  {
   "file": "f1",
   "name": "2024-06-15-wedding-phone-143201.jpg",
   "why": ""
  },
  {
   "file": "f2",
   "name": "2024-06-15-wedding-phone-143455.jpg",
   "why": ""
  },
  {
   "file": "f3",
   "name": "2024-06-15-wedding-phone-170512.jpg",
   "why": ""
  },
  {
   "file": "f4",
   "name": "2024-06-15-wedding-camera-00412.arw",
   "why": "date comes from your description, not the photo's own name"
  },
  {
   "file": "f5",
   "name": "2024-06-15-wedding-camera-00413.arw",
   "why": "date comes from your description, not the photo's own name"
  },
  {
   "file": "f6",
   "name": "2024-06-15-wedding-camera-00419.arw",
   "why": "date comes from your description, not the photo's own name"
  },
  {
   "file": "f7",
   "name": "2024-06-15-wedding-phone-181045123.jpg",
   "why": ""
  }
 ],
 "unsure": [],
 "note": "This sorts all seven photos by date, by whether your camera or your phone took them, and by their own number, with nothing left to ask."
}

The scheme: YYYY-MM-DD-event-camera-number.ext - "The date, which device took the photo, and the number in its name set the wedding photos apart."

The renames the page trusts (ReplyKit.trusted, every name checked again), old against new; a file with no new name keeps its own:

Old nameNew nameStatusIssuesWhy
IMG_20240615_143201.jpg2024-06-15-wedding-phone-143201.jpgrename--
IMG_20240615_143455.jpg2024-06-15-wedding-phone-143455.jpgrename--
IMG_20240615_170512.JPG2024-06-15-wedding-phone-170512.jpgrename--
DSC00412.ARW2024-06-15-wedding-camera-00412.arwrename-date comes from your description, not the photo's own name
DSC00413.ARW2024-06-15-wedding-camera-00413.arwrename-date comes from your description, not the photo's own name
DSC00419.ARW2024-06-15-wedding-camera-00419.arwrename-date comes from your description, not the photo's own name
PXL_20240615_181045123.jpg2024-06-15-wedding-phone-181045123.jpgrename--

The questions:

The verdict: consistent - "Every file fits the scheme"; it agrees with the questions: no file is left with a question.

The note: "This sorts all seven photos by date, by whether your camera or your phone took them, and by their own number, with nothing left to ask."

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

No notes.

A thesis folder

The form: the files are My master's thesis drafts and notes; the parts wanted, in order: thesis, chapter or part, version. The rule settings: no find and replace, dates iso, copy marks drop, separator -, case lower, extension case lower, no prefix, numbering none. The file names as typed:

thesis draft v2.docx
Thesis_v3_comments from Dr Lee.docx
thesis FINAL.docx
chapter 2 - methods (old).docx
Chapter2_Methods_v4.docx
lit review notes.md
references.bib

The names as the page reads them (NameKit.form): the first date in each, its version tag and its extension.

FileNameDateVersionExtension
f1thesis draft v2.docxnone2.docx
f2Thesis_v3_comments from Dr Lee.docxnone3.docx
f3thesis FINAL.docxnonenone.docx
f4chapter 2 - methods (old).docxnonenone.docx
f5Chapter2_Methods_v4.docxnone4.docx
f6lit review notes.mdnonenone.md
f7references.bibnonenone.bib

The page's own renames by those rules, free and before any run (NameKit.check(f, NameKit.ruleNames(f))):

Old nameNew nameStatusIssues
thesis draft v2.docxthesis-draft-v2.docxrename-
Thesis_v3_comments from Dr Lee.docxthesis-v3-comments-from-dr-lee.docxrename-
thesis FINAL.docxthesis.docxrename-
chapter 2 - methods (old).docxchapter-2-methods-old.docxrename-
Chapter2_Methods_v4.docxchapter2-methods-v4.docxrename-
lit review notes.mdlit-review-notes.mdrename-
references.bibreferences.bibunchanged-

Input:

{
 "task": "names",
 "goal": "My master's thesis drafts and notes",
 "scheme": "thesis, chapter or part, version",
 "files": "f1. thesis draft v2.docx | date: none | version: 2 | extension: .docx\nf2. Thesis_v3_comments from Dr Lee.docx | date: none | version: 3 | extension: .docx\nf3. thesis FINAL.docx | date: none | version: none | extension: .docx\nf4. chapter 2 - methods (old).docx | date: none | version: none | extension: .docx\nf5. Chapter2_Methods_v4.docx | date: none | version: 4 | extension: .docx\nf6. lit review notes.md | date: none | version: none | extension: .md\nf7. references.bib | date: none | version: none | extension: .bib",
 "counts": "7 files; 0 with a date; 3 kinds of extension"
}

Reply:

{
 "task": "names",
 "verdict": "consistent",
 "scheme": {
  "pattern": "thesis-or-chapter-or-part-vN.ext",
  "why": "Follows the order you gave: the thesis, chapter, or part name, then version when present, before the extension."
 },
 "names": [
  {
   "file": "f1",
   "name": "thesis-draft-v2.docx",
   "why": ""
  },
  {
   "file": "f2",
   "name": "thesis-comments-v3.docx",
   "why": "Dropped the name of the person who commented; kept comments and version."
  },
  {
   "file": "f3",
   "name": "thesis-final.docx",
   "why": ""
  },
  {
   "file": "f4",
   "name": "chapter-2-methods-old.docx",
   "why": ""
  },
  {
   "file": "f5",
   "name": "chapter-2-methods-v4.docx",
   "why": ""
  },
  {
   "file": "f6",
   "name": "lit-review-notes.md",
   "why": ""
  },
  {
   "file": "f7",
   "name": "references.bib",
   "why": ""
  }
 ],
 "unsure": [],
 "note": "Each file gets a lowercase hyphenated name built from the thesis, chapter, or part it belongs to and its version, with no files needing your input."
}

The scheme: thesis-or-chapter-or-part-vN.ext - "Follows the order you gave: the thesis, chapter, or part name, then version when present, before the extension."

The renames the page trusts (ReplyKit.trusted, every name checked again), old against new; a file with no new name keeps its own:

Old nameNew nameStatusIssuesWhy
thesis draft v2.docxthesis-draft-v2.docxrename--
Thesis_v3_comments from Dr Lee.docxthesis-comments-v3.docxrename-Dropped the name of the person who commented; kept comments and version.
thesis FINAL.docxthesis-final.docxrename--
chapter 2 - methods (old).docxchapter-2-methods-old.docxrename--
Chapter2_Methods_v4.docxchapter-2-methods-v4.docxrename--
lit review notes.mdlit-review-notes.mdrename--
references.bibreferences.bibunchanged--

The questions:

The verdict: consistent - "Every file fits the scheme"; it agrees with the questions: no file is left with a question.

The note: "Each file gets a lowercase hyphenated name built from the thesis, chapter, or part it belongs to and its version, with no files needing your input."

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

No notes.

The rename script for the first sample

What NameKit.bash(t.rows, false) writes for "A year of invoices" above: the 5 renames whose status is ok, in two phases through temporary names, so no rename can overwrite another file. Files that keep their name, and any name with a problem, are left out. NameKit.bash(t.rows, true) writes the undo script, and NameKit.powershell the same pair for Windows.

#!/usr/bin/env bash
# Tidy Names - 5 renames. Run it in the folder that holds the files.
# Every file moves to a temporary name first, so no rename can overwrite another file.
set -euo pipefail

mv -n -- 'Invoice_ACME_march2024.pdf' '.tidy-names-1.tmp'
mv -n -- 'acme invoice 2024-04-02 FINAL.pdf' '.tidy-names-2.tmp'
mv -n -- 'Copy of Invoice Bluebird 14 June 2024.pdf' '.tidy-names-3.tmp'
mv -n -- 'Northwind inv 12 Jan 2024.PDF' '.tidy-names-4.tmp'
mv -n -- 'northwind_invoice_2024_02_09.pdf' '.tidy-names-5.tmp'

mv -n -- '.tidy-names-1.tmp' '2024-03-acme-invoice.pdf'
mv -n -- '.tidy-names-2.tmp' '2024-04-02-acme-invoice.pdf'
mv -n -- '.tidy-names-3.tmp' '2024-06-14-bluebird-invoice.pdf'
mv -n -- '.tidy-names-4.tmp' '2024-01-12-northwind-invoice.pdf'
mv -n -- '.tidy-names-5.tmp' '2024-02-09-northwind-invoice.pdf'

Build the input yourself

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

const K = NameKit, R = ReplyKit;
const form = K.form({ goal: "Receipts for my tax return", scheme: "date, shop, receipt",
  files: "Scan 2024-03-05 Tesco.pdf\nreceipt ikea march 2024.PDF" });
// form.files: every name as read [{id, path, dir, stem, ext, date: {iso, ambiguous} or null, version}]
K.check(form, K.ruleNames(form))      // the free rule renames, every one checked, no run needed
const built = K.buildInput(form);     // built.ok is false, with built.note, until there is a name
// built.sent: the files in the input; built.rules: the rule renames as checked; built.warn: what the page warns about
const wire = K.toWire(built.input);   // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output));
// const t = R.trusted(reply, built)      // {names: {f1: new name}, asked, dropped, missing, rows, why}
// R.check(reply, built)                   // {status: kept or flagged, notes}
// K.bash(t.rows, false)                   // the rename script; true for the undo script
// K.toCSV(t.rows)                         // old, new, status, issues

Form keys: files (the names, one per line), goal (what the files are, or empty), scheme (the parts wanted in each name, in order, or empty), and the rule settings: find and replace (plain text), dates (iso, front or keep), noise (drop or keep), sep (-, _, space or keep), kase (lower, title, upper or keep), ext (lower or keep), prefix (plain text) and number (none, prefix or suffix). The rule settings shape only the free renames; the input sends the goal, the scheme and the names as read.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here"   # from https://tidy-names.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"tidy-names"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "names", "goal": "Receipts for my tax return", "scheme": "date, shop, receipt", "files": "f1. Scan 2024-03-05 Tesco.pdf | date: 2024-03-05 | version: none | extension: .pdf\nf2. receipt ikea march 2024.PDF | date: 2024-03 | version: none | extension: .PDF", "counts": "2 files; 2 with a date; 1 kind of extension"}
# - see the field table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses tidy-names:<task>:<inputHash>:a<attempt> (the one task is names), 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="tidy-names:names:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r '.verdict, .scheme.pattern, (.names[] | "\(.file) \(.name)")' reply.json

Step 6 — POST /run-stream

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

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