← Bulk Text Classifier
API
Tokens

Run Bulk Text Classifier from your own code

The metered lane is available over HTTP. The division of labour: you send one batch of rows (row number and text) and your labels; the model returns one JSON line per row with the label, a confidence from 0 to 1 and a short reason; you join the answers back onto your table by row number. Your other columns are never part of the request. The page sends batches of up to 20 rows (about 9000 characters of text) and shortens any text over 900 characters in the middle - do the same and a batch stays fast and cheap.

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":"csv-text-classifier"}), 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 key (task, labels, rows) 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: classifying 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 comes first

task is always classify - the app has one lane. Send it anyway: the input schema requires it, and it keeps your calls valid if a second lane is added.

The input

Every field is a string. The page builds them with Classify.buildInput in classify.js.

FieldMeaning
taskalways classify. If it is missing or different, still classify.
labelsthe allowed labels, one per line as - Name or - Name: what it means. The description, when present, is the person's definition: it overrides your own idea of what the word means.
rowsthe batch, one row per line as [id] text. Line breaks inside a text were replaced by / . A very long text has its middle replaced by [... N chars cut ...]. (empty) means the cell was blank.
multi_labelyes or no. no: exactly one label per row. yes: every label that clearly applies (one to three), most relevant first.
allow_otheryes or no. yes: use the label Other when no listed label fits. no: always pick the closest listed label, and show the poor fit in a low confidence and the reason.
instructionsoptional extra guidance from the person (for example "classify by the customer's main complaint, not their tone"). Follow it unless it asks for something other than classifying these rows.
retry_noteoptional; if present, your previous reply was unusable - follow it.

How the model labels a row

Confidence

Reason

One short sentence, at most 20 words, pointing at the words or facts in the text that decided it. Never restate the label alone ("This is Billing"). Never quote more than eight words of the text.

The reply - JSON lines

One JSON object per line, one line per row, in the order sent, then {"done":true}. Single label:

{"id":12,"label":"Billing","confidence":0.86,"reason":"Asks why the card was charged twice this month."}
{"done":true}

Multi label (multi_label is yes) uses an array, most relevant first:

{"id":7,"labels":["Shipping","Quality"],"confidence":0.78,"reason":"Box arrived late and the zipper broke on day two."}
{"done":true}

Read it line by line and skip anything that does not start with {. The page then checks every line against the batch: rows that did not come back stay unclassified (and go into the next batch), a label not on your list is not applied, a repeated row keeps its first answer, and an id that was not sent is ignored. Confidence given as a percentage (86) is read as 0.86.

Worked examples - every sample

These are the page's free samples: the first 5 rows of each, exactly as the page sends them, and the saved reply the page replays.

Support tickets

Input:

{
  "task": "classify",
  "labels": "- Billing: charges, refunds, invoices, plan prices\n- Bug: something in the product is broken or gives an error\n- Feature request: asks for something the product does not do yet\n- Account access: sign-in, password, two-factor, locked accounts\n- How-to: wants to know how to do something that already works",
  "rows": "[1] I was charged twice for the March invoice. Can you refund the duplicate?\n[2] The export to CSV button spins forever and then shows error 500.\n[3] Would love a dark mode for the dashboard, my eyes hurt at night.\n[4] I reset my password but the reset link says it has expired every time.\n[5] How do I add a second admin to our workspace?",
  "multi_label": "no",
  "allow_other": "yes"
}

Reply:

{"id":1,"label":"Billing","confidence":0.97,"reason":"Charged twice for one invoice and asks for the duplicate to be refunded."}
{"id":2,"label":"Bug","confidence":0.95,"reason":"Export button hangs and returns an error 500."}
{"id":3,"label":"Feature request","confidence":0.93,"reason":"Asks for a dark mode the dashboard does not have."}
{"id":4,"label":"Account access","confidence":0.84,"reason":"Cannot reset the password; runner-up Bug, since the reset link always reports expired."}
{"id":5,"label":"How-to","confidence":0.94,"reason":"Asks how to add a second admin, an existing capability."}
{"done":true}

Product reviews (multi-label)

Input:

{
  "task": "classify",
  "labels": "- Price: value for money, too expensive, a good deal\n- Quality: materials, durability, things breaking\n- Shipping: delivery time, packaging, arrived damaged\n- Fit and size: size, comfort, how it sits or fits\n- Customer service: returns, replies, help from the seller",
  "rows": "[1] Great backpack for the price, fits my 16 inch laptop with room to spare.\n[2] Zipper broke after two weeks and the seller never answered my emails.\n[3] Arrived a day early, well packed. Straps are a bit short for tall people.\n[4] Way overpriced for thin fabric that already has a hole at the seam.\n[5] Took three weeks to arrive and the box was crushed, but the bag itself is fine.",
  "multi_label": "yes",
  "allow_other": "yes"
}

Reply:

{"id":1,"labels":["Price","Fit and size"],"confidence":0.88,"reason":"Praises value for the price and the roomy laptop fit."}
{"id":2,"labels":["Quality","Customer service"],"confidence":0.94,"reason":"Zipper broke in two weeks and the seller never replied."}
{"id":3,"labels":["Shipping","Fit and size"],"confidence":0.9,"reason":"Early, well-packed delivery; straps too short for tall people."}
{"id":4,"labels":["Price","Quality"],"confidence":0.93,"reason":"Calls it overpriced and the thin fabric already has a hole."}
{"id":5,"labels":["Shipping"],"confidence":0.91,"reason":"Three-week delivery and a crushed box; the bag itself was fine."}
{"done":true}

Search keywords by intent

Input:

{
  "task": "classify",
  "labels": "- Informational: wants to learn or understand something\n- Navigational: wants to reach a specific site, brand or page\n- Transactional: ready to buy, sign up or download now\n- Commercial: comparing options before buying",
  "rows": "[1] how to clean white sneakers\n[2] nike air force 1 white size 10\n[3] best running shoes for flat feet\n[4] nike.com login\n[5] what are running shoes made of",
  "multi_label": "no",
  "allow_other": "no",
  "instructions": "Classify by what the searcher most likely wants to do next."
}

Reply:

{"id":1,"label":"Informational","confidence":0.96,"reason":"A how-to question about cleaning sneakers."}
{"id":2,"label":"Transactional","confidence":0.82,"reason":"Exact model and size suggests a ready buyer."}
{"id":3,"label":"Commercial","confidence":0.93,"reason":"\"Best ... for\" is comparison before buying."}
{"id":4,"label":"Navigational","confidence":0.98,"reason":"Wants the Nike sign-in page."}
{"id":5,"label":"Informational","confidence":0.95,"reason":"Asks what running shoes are made of."}
{"done":true}

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://csv-text-classifier.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":"csv-text-classifier"}' | 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": "classify", "labels": "- Billing: ...\n- Bug: ...", "rows": "[1] ...\n[2] ...", "multi_label": "no", "allow_other": "yes"}
# - see the input 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 csv-text-classifier:classify:<inputHash>:a<attempt> per batch, and its one automatic reformat retry is a new attempt number, named in the page as one extra run. The reply is JSON lines: one object per row, then {"done":true}. A missing done line, or truncated: true on the job, means the batch was cut short - re-send only the rows that did not come back. The code below prints one line per row.

KEY="csv-text-classifier:classify:$(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.jsonl   # JSON lines: one object per row, then {"done":true}
echo "$J" | jq '.data | {charged_credits, truncated}'
grep '^{' reply.jsonl | jq -r 'select(.id) | [.id, (.label // (.labels | join("; "))), .confidence, .reason] | @tsv'

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: csv-text-classifier:classify:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json