← Four Tones
API
Tokens

Drive Four Tones from your own code

Both metered tasks - the phrase set and the pinyin check - are available over HTTP. A natural use is a language-club page or a flashcard tool that wants tone-numbered phrases for a topic, or wants to grade a learner's pinyin.

The split first: the page does the sound and the arithmetic; the model writes the Mandarin; the page checks it. Pinyin parsing, the syllable inventory, tone changes, contours and the tone-pair drill live in tonekit.js, which runs in node as well as in the browser. Its reconcile function recounts every reply: one valid syllable per character, the listed tone changes against its own rules, the tone load and the check verdict.

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":"four-tones"}), so no slug header is needed afterwards. Send it as Authorization: Bearer …. The run body is the input object, sent bare: never wrap it as {"input": {…}}.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required field 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: both tasks are metered and need a personal token.
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

Every run sends task, which routes the one system prompt to one of two contracts:

taskWhat it returnsVerdict field
phrasesA title, a summary, and phrases[] of {hanzi, pinyin, english, literal, sandhi[], note}; 2–4 tips; 0–2 caveats. sandhi lists every 3-3, 一 and 不 change as {index, char, to, rule} (1-based syllable index).tone_load: light (under 25% of syllables are third tones or change), moderate (under 50%), heavy
checkThe dictionary pinyin in expected, the English meaning, syllables[] of {hanzi, yours, expected, issue, note} with issue one of ok tone spelling missing extra, a spoken_note, 2–4 drills of {hanzi, pinyin, english, why}, tips, caveats.verdict: spot_on (no errors), minor_slips (up to a quarter of the syllables, at least one), needs_work

Pinyin in every reply uses tone numbers, one syllable per character, dictionary tones (ni3 hao3, yi1 ge4), v for u-umlaut and 5 for the neutral tone. In a check, a learner's tone counts as right when it is either the dictionary tone or the spoken tone after a change (ni2 hao3 is fine).

The declared input schema carries scalars only: every value is a string, and prescan travels JSON-encoded (ToneKit.toWire does this). Only task is required.

Input fields

FieldTypeNotes
taskstringphrases or check.
situationstringphrases: where you are and what you need to say. Over 600 characters is cut at a sentence end.
levelstringphrases: beginner (2–7 characters a phrase), intermediate (up to 12), advanced (up to 18).
aimstringphrases: everyday, easy_tones (avoid third tones and changes where natural), tone_workout (pack them in).
countstringphrases: "3" to "8".
focus_pairsstringphrases, optional: weak tone pairs from the drill, e.g. "3-3, 2-3"; at least half the phrases then contain each pair.
hanzistringcheck: the sentence in simplified characters, up to 40 characters (cut at punctuation).
attemptstringcheck: the learner's pinyin, tone marks or numbers.
meaningstringcheck, optional: what the learner thinks it means.
prescanstring (JSON)check: {characters, syllables_written, toneless, not_pinyin[], attempt[]} from ToneKit.buildInput.
retry_notestringOnly on a reformat retry.

The reply

The job's output.output is a string holding one JSON object. Parse it with ToneKit.parseReply, then normalize, then check it with reconcile(reply, ctx) - {count, focusPairs} for phrases, {hanzi, attempt} for a check. It flags syllables that are not Mandarin, a character count that differs from the syllable count, tone changes that differ from its rules, a tone load or verdict its own count does not support, and drill lines that do not line up.

Worked examples

Replies produced against the app's prompt for the three samples (long lists shortened; the full replies ship in example-result.js). The page replays each of them free from its sample buttons. Between them they show every verdict value.

task: phrases - hot water and congee (light)

Input:

{
 "task": "phrases",
 "situation": "Ordering breakfast at a congee stall: hot water, congee, soy milk, paying by phone.",
 "level": "beginner",
 "aim": "easy_tones",
 "count": "5",
 "focus_pairs": ""
}

Reply:

{
 "task": "phrases",
 "title": "Ordering at a Congee Stall (Easy Tones)",
 "summary": "Five simple phrases for ordering hot water, congee, and soy milk, and for paying by phone at a congee stall, chosen to dodge tone-3 pairs and yi/bu changes. Listen for the low dip on 水, 碗, 手, and 少 - the only third tones in the set.",
 "tone_load": "light",
 "phrases": [
  {
   "hanzi": "来杯热水",
   "pinyin": "lai2 bei1 re4 shui3",
   "english": "A cup of hot water, please.",
   "literal": "come cup hot water",
   "sandhi": [],
   "note": "\"来 + cup/bowl + item\" is a casual, friendly way to order - no need for 一 at all."
  },
  {
   "hanzi": "来碗粥",
   "pinyin": "lai2 wan3 zhou1",
   "english": "A bowl of congee, please.",
   "literal": "come bowl congee",
   "sandhi": [],
   "note": "碗 (wan3, \"bowl\") is congee's natural measure word - drop the number when ordering."
  },
  {
   "hanzi": "来杯豆浆",
   "pinyin": "lai2 bei1 dou4 jiang1",
   "english": "A cup of soy milk, please.",
   "literal": "come cup soybean-milk",
   "sandhi": [],
   "note": "豆浆 is the classic hot soy milk served alongside congee at breakfast stalls."
  },
  "..."
 ],
 "tips": [
  "No 一 or 不 appear in this set, so there's no tone-change rule to apply - what you see in pinyin is exactly what you say.",
  "Only four syllables here are dictionary third tone (水, 碗, 手, 少), and none sits next to another third tone, so each gets its full low dip.",
  "Watch the rising second tone on 来, 钱, and 糖 - don't let them flatten into tone 1."
 ],
 "caveats": [
  "来杯/来碗 is casual spoken style; 我要一杯/一碗 is a slightly more formal version, but it does add a 一 tone change."
 ]
}

task: check - hot water and congee (spot_on)

Input:

{
 "task": "check",
 "hanzi": "我要一杯热水。",
 "attempt": "wo3 yao4 yi1 bei1 re4 shui3",
 "meaning": "I want a cup of hot water",
 "prescan": "{\"characters\":6,\"syllables_written\":6,\"toneless\":false,\"not_pinyin\":[],\"attempt\":[\"wo3\",\"yao4\",\"yi1\",\"bei1\",\"re4\",\"shui3\"]}"
}

Reply:

{
 "task": "check",
 "verdict": "spot_on",
 "summary": "Spot on - every syllable matches the dictionary pinyin exactly, and your meaning \"I want a cup of hot water\" is correct.",
 "expected": "wo3 yao4 yi1 bei1 re4 shui3",
 "meaning": "I want a cup of hot water",
 "syllables": [
  {
   "hanzi": "我",
   "yours": "wo3",
   "expected": "wo3",
   "issue": "ok",
   "note": ""
  },
  {
   "hanzi": "要",
   "yours": "yao4",
   "expected": "yao4",
   "issue": "ok",
   "note": ""
  },
  {
   "hanzi": "一",
   "yours": "yi1",
   "expected": "yi1",
   "issue": "ok",
   "note": ""
  },
  "..."
 ],
 "spoken_note": "一 shifts before a tone-1 syllable: 一杯 is spoken yi4 bei1, not yi1 bei1. wo3 isn't next to another third tone, so it just dips low instead of rising. shui3, at the end of the sentence, keeps its full dip-and-rise third tone.",
 "drills": [
  {
   "hanzi": "一杯茶",
   "pinyin": "yi1 bei1 cha2",
   "english": "a cup of tea",
   "why": "the same yi1-before-tone-1 change as 一杯, spoken yi4 bei1 cha2"
  },
  {
   "hanzi": "我家",
   "pinyin": "wo3 jia1",
   "english": "my home",
   "why": "wo3 here isn't next to another third tone either, so it dips low just like in your sentence"
  },
  {
   "hanzi": "喝水",
   "pinyin": "he1 shui3",
   "english": "drink water",
   "why": "shui3 is sentence-final again, so it keeps the full dip-and-rise"
  }
 ],
 "tips": [
  "Great job: every tone and spelling matched the dictionary form.",
  "Keep an ear out for 一 before a tone-1 syllable (like 杯) - it's spoken as yi4, even though the dictionary tone stays yi1.",
  "Lone third tones (not next to another one) just dip low in fast speech; only a third tone at the very end gets the full dip-and-rise."
 ],
 "caveats": []
}

task: phrases - morning tai chi (moderate)

Input:

{
 "task": "phrases",
 "situation": "Joining the neighbours for morning tai chi in the park and chatting afterwards.",
 "level": "beginner",
 "aim": "everyday",
 "count": "6",
 "focus_pairs": ""
}

Reply:

{
 "task": "phrases",
 "title": "Morning Tai Chi in the Park",
 "summary": "Six everyday phrases for greeting the neighbours, joining their tai chi, and chatting afterwards - listen for the 3rd-tone dips and how 一 shifts before 起.",
 "tone_load": "moderate",
 "phrases": [
  {
   "hanzi": "您早",
   "pinyin": "nin2 zao3",
   "english": "Good morning",
   "literal": "you early",
   "note": "您 is the polite 'you' - natural when greeting older neighbours you don't know well yet.",
   "sandhi": []
  },
  {
   "hanzi": "一起打太极拳",
   "pinyin": "yi1 qi3 da3 tai4 ji2 quan2",
   "english": "Let's do tai chi together",
   "literal": "together hit tai-chi fist",
   "note": "一 shifts to the 4th tone (yi4) before the 3rd-tone 起, which itself rises before 打.",
   "sandhi": [
    {
     "index": 1,
     "char": "一",
     "to": 4,
     "rule": "yi"
    },
    {
     "index": 2,
     "char": "起",
     "to": 2,
     "rule": "3-3"
    }
   ]
  },
  {
   "hanzi": "今天天气真好",
   "pinyin": "jin1 tian1 tian1 qi4 zhen1 hao3",
   "english": "The weather's really nice today",
   "literal": "today weather really good",
   "note": "A classic opener - safe small talk anywhere in China.",
   "sandhi": []
  },
  "..."
 ],
 "tips": [
  "Third tones that aren't last in their group turn into a short rising tone, not a deep dip.",
  "一 is a chameleon: it's yi4 before 起 here because 起 is a 3rd tone.",
  "Three 3rd tones in a row (我也想) only keep the full dip on the last one."
 ],
 "caveats": [
  "您 is the respectful form; close friends or younger neighbours often just say 你."
 ]
}

task: check - morning tai chi (minor_slips)

Input:

{
 "task": "check",
 "hanzi": "早上好,我们一起打太极吧!",
 "attempt": "zao3 shang4 hao3, wo3 men yi4 qi3 da3 tai4 ji1 ba",
 "meaning": "Good morning, let's do tai chi together",
 "prescan": "{\"characters\":11,\"syllables_written\":11,\"toneless\":false,\"not_pinyin\":[],\"attempt\":[\"zao3\",\"shang4\",\"hao3\",\"wo3\",\"men5\",\"yi4\",\"qi3\",\"da3\",\"tai4\",\"ji1\",\"ba5\"]}"
}

Reply:

{
 "task": "check",
 "verdict": "minor_slips",
 "summary": "Meaning's right, and your yi-sandhi instinct was good (一起 said as yi4 qi3) - two small tone slips: 上 in 早上 is neutral (shang5), not shang4, and 极 in 太极 is 2nd tone (ji2), not 1st.",
 "expected": "zao3 shang5 hao3 wo3 men5 yi1 qi3 da3 tai4 ji2 ba5",
 "meaning": "Good morning, let's do tai chi together!",
 "syllables": [
  {
   "hanzi": "早",
   "yours": "zao3",
   "expected": "zao3",
   "issue": "ok",
   "note": ""
  },
  {
   "hanzi": "上",
   "yours": "shang4",
   "expected": "shang5",
   "issue": "tone",
   "note": "上 in 早上 is neutral tone (shang5) in the dictionary, not a full shang4"
  },
  {
   "hanzi": "好",
   "yours": "hao3",
   "expected": "hao3",
   "issue": "ok",
   "note": "full 3rd tone here - the comma pauses before 我们, so no 3-3 change"
  },
  "..."
 ],
 "spoken_note": "早上's 上 is unstressed (shang5), not a full 4th tone. 一 shifts to yi4 before the 3rd-tone 起 (一起 sounds like yi4 qi3), and 起+打 are two 3rd tones back to back, so 起 rises into a 2nd tone (qi2) while 打 keeps the 3rd tone. 早, 好 and 我 are said as a quick low dip rather than the full dip-and-rise mid-sentence. The comma right after 好 marks a small pause, so 好 does not merge into 我们 with a 3-3 change.",
 "drills": [
  {
   "hanzi": "晚上",
   "pinyin": "wan3 shang5",
   "english": "evening",
   "why": "same neutral shang5 as in 早上 - no 4th tone needed"
  },
  {
   "hanzi": "太极拳",
   "pinyin": "tai4 ji2 quan2",
   "english": "tai chi (the martial art)",
   "why": "same ji2 you wrote as ji1 - it's 2nd tone, not 1st"
  },
  {
   "hanzi": "积极",
   "pinyin": "ji1 ji2",
   "english": "proactive, positive",
   "why": "puts ji1 and ji2 side by side, the exact two tones you mixed up"
  }
 ],
 "tips": [
  "早上's 上 is neutral tone (shang5) in the dictionary, not a full 4th tone - easy to over-pronounce since 上 alone is shang4.",
  "极 in 太极 is 2nd tone (ji2), rising - think of 积极's second syllable.",
  "Your yi-sandhi was already right: 一起 is said yi4 qi3, and that's exactly what you wrote."
 ],
 "caveats": [
  "Some careful or regional speech keeps a fuller tone on 上 in 早上; dictionaries and this check use the neutral shang5."
 ]
}

task: phrases - goji at the market (heavy)

Input:

{
 "task": "phrases",
 "situation": "Buying goji berries and red dates at a market stall and haggling a little.",
 "level": "intermediate",
 "aim": "tone_workout",
 "count": "6",
 "focus_pairs": ""
}

Reply:

{
 "task": "phrases",
 "title": "Haggling for Goji Berries and Red Dates",
 "summary": "Six market phrases for pricing, praising, and bargaining over goji berries and red dates. Listen for the long third-tone chains (老板, 给我, 也很好) flattening to second tone, and for 一 and 不 shifting before certain tones.",
 "tone_load": "heavy",
 "phrases": [
  {
   "hanzi": "老板,枸杞怎么卖?",
   "pinyin": "lao3 ban3 gou3 qi3 zen3 me5 mai4",
   "english": "Boss, how do you sell the goji berries?",
   "literal": "boss goji-berries how sell",
   "sandhi": [
    {
     "index": 1,
     "char": "老",
     "to": 2,
     "rule": "3-3"
    },
    {
     "index": 2,
     "char": "板",
     "to": 2,
     "rule": "3-3"
    },
    {
     "index": 3,
     "char": "枸",
     "to": 2,
     "rule": "3-3"
    },
    {
     "index": 4,
     "char": "杞",
     "to": 2,
     "rule": "3-3"
    }
   ],
   "note": "Five third tones in a row (老板枸杞怎) - all but the last (怎) flatten to second tone when spoken."
  },
  {
   "hanzi": "红枣也很好吃。",
   "pinyin": "hong2 zao3 ye3 hen3 hao3 chi1",
   "english": "The red dates are tasty too.",
   "literal": "red-date also very good-eat",
   "sandhi": [
    {
     "index": 2,
     "char": "枣",
     "to": 2,
     "rule": "3-3"
    },
    {
     "index": 3,
     "char": "也",
     "to": 2,
     "rule": "3-3"
    },
    {
     "index": 4,
     "char": "很",
     "to": 2,
     "rule": "3-3"
    }
   ],
   "note": "枣也很 is a four-syllable third-tone run ending in 好; only 好 keeps its full dip."
  },
  {
   "hanzi": "可以便宜一点吗?",
   "pinyin": "ke3 yi3 pian2 yi5 yi1 dian3 ma5",
   "english": "Can it be a little cheaper?",
   "literal": "can cheap a-little (question)",
   "sandhi": [
    {
     "index": 1,
     "char": "可",
     "to": 2,
     "rule": "3-3"
    },
    {
     "index": 5,
     "char": "一",
     "to": 4,
     "rule": "yi"
    }
   ],
   "note": "可以 becomes ke2 yi3 when spoken, and 一 jumps to the 4th tone before the third-tone 点."
  },
  "..."
 ],
 "tips": [
  "Third-tone chains (老板枸杞怎, 枣也很好, 给我) all flatten to second tone except the final syllable in the run.",
  "一 and 不 only change before specific tones: here 一 shifts before the third-tone 点, and 不 shifts before the fourth-tone 贵.",
  "Keep neutral-tone syllables like 吧, 吗, 么 and 宜 short and light - don't give them a full tone."
 ],
 "caveats": [
  "Vendors may speak quickly with local fillers; the tones above are the standard Putonghua citation and sandhi forms."
 ]
}

task: check - goji at the market (needs_work)

Input:

{
 "task": "check",
 "hanzi": "老板,枸杞和红枣多少钱一斤?",
 "attempt": "lao4 ban1 gou1 qi2 he2 hong2 zao1 duo1 shao3 qian1 yi1 jing1",
 "meaning": "Boss, how much are the goji and dates?",
 "prescan": "{\"characters\":12,\"syllables_written\":12,\"toneless\":false,\"not_pinyin\":[],\"attempt\":[\"lao4\",\"ban1\",\"gou1\",\"qi2\",\"he2\",\"hong2\",\"zao1\",\"duo1\",\"shao3\",\"qian1\",\"yi1\",\"jing1\"]}"
}

Reply:

{
 "task": "check",
 "verdict": "needs_work",
 "summary": "You nailed 和, 红, 多, 少 and wrote 一 as its correct citation tone, but tones slipped on 老, 板, 枸, 杞, 枣 and 钱, and 斤 came out spelled 'jing' instead of 'jin'. Your English meaning is close, it just leaves out that the price is per jin (about 500g).",
 "expected": "lao3 ban3 gou3 qi3 he2 hong2 zao3 duo1 shao3 qian2 yi1 jin1",
 "meaning": "Boss, how much per jin (about 500g) for the goji berries and red dates?",
 "syllables": [
  {
   "hanzi": "老",
   "yours": "lao4",
   "expected": "lao3",
   "issue": "tone",
   "note": "Right before another 3rd tone (板), so it's said lao2; you wrote tone 4."
  },
  {
   "hanzi": "板",
   "yours": "ban1",
   "expected": "ban3",
   "issue": "tone",
   "note": "Stays a plain 3rd tone here (the comma breaks the chain); you wrote tone 1."
  },
  {
   "hanzi": "枸",
   "yours": "gou1",
   "expected": "gou3",
   "issue": "tone",
   "note": "Right before another 3rd tone (杞), so it's said gou2; you wrote tone 1."
  },
  "..."
 ],
 "spoken_note": "老板 is said lao2 ban3 (two 3rd tones in a row, so the first rises); 枸杞 is said gou2 qi3 (same rule); 枣 and 少 are each their own 3rd tone with no neighbor to trigger a change, so they just dip; 一 sits before jin1 (a 1st tone), so it's actually spoken yi4 even though the dictionary form yi1 is what you write.",
 "drills": [
  {
   "hanzi": "老板",
   "pinyin": "lao3 ban3",
   "english": "boss",
   "why": "the exact 3-3 change you missed, said lao2 ban3"
  },
  {
   "hanzi": "枸杞",
   "pinyin": "gou3 qi3",
   "english": "goji berry",
   "why": "the word you mis-toned, said gou2 qi3"
  },
  {
   "hanzi": "多少钱",
   "pinyin": "duo1 shao3 qian2",
   "english": "how much money",
   "why": "practice qian2's rising tone, which you flattened to qian1"
  },
  "..."
 ],
 "tips": [
  "老板 and 枸杞 each stack two 3rd tones - the first always rises to a 2nd tone (lao2 ban3, gou2 qi3).",
  "钱 is qian2, rising, not flat qian1.",
  "斤 ends in -in, not -ing - drop the 'g' sound."
 ],
 "caveats": []
}

Build the input yourself

Load the script in node (global.window = {}; require("./tonekit.js")), then:

const K = ToneKit;
const { input } = K.buildInput("phrases", { situation: "Ordering congee", level: "beginner", aim: "everyday", count: 5, focusPairs: ["3-3"] });
const wire = K.toWire(input);                      // POST this to /estimate and /run
// check: K.buildInput("check", { hanzi: "我要一杯热水", attempt: "wo3 yao4 yi1 bei1 re4 shui3", meaning: "" })
// later: const reply = K.normalize(K.parseReply(job.output.output));
// K.reconcile(reply, { count: 5, focusPairs: ["3-3"] }) -> [{level, code, text}]
// the melody of any line: K.analyze("你好", "ni3 hao3").rows -> [{lex, spoken, contour, rule}, ...]

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://four-tones.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":"four-tones"}' | 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": "check", "hanzi": "我要一杯热水", "attempt": "wo3 yao4 yi1 bei1 re4 shui3", "prescan": "{...}"}
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 four-tones:<task>:<inputHash>:a<attempt>, 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="four-tones:check:$(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}' 

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: four-tones:check:$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json