← Tell Desk / API
Tokens

Drive Tell Desk from your own code

The page does its free work in the browser: it splits a draft into blocks, sets aside code, quotations, block quotes and URLs, picks a context profile, matches the tiered vocabulary, runs the catalogue's patterns, measures rhythm and raises flags TD-01..TD-39. Its two paid lanes are available over HTTP. You send one JSON object with task set to "audit" or "rewrite", and the model replies with one JSON object. The easiest way to build the facts string is the page itself: Tellkit.buildInput(lane, Tellkit.analyze(draft, {context, voice}), {note}) in tellkit.js, which also runs under Node.

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>. Both lanes run on gpt-terra with a 10% publisher markup. A run needs a signed-in (user) token and is metered: the platform holds the estimate's hold_credits while the run is in flight and then charges only what it used (charged_credits). /estimate is free. A plain-text summary is at /llms.txt.

The response envelope

Every response is {"data": ..., "error": null} on success or {"data": null, "error": {"code", "message"}} on failure.

Error codes

HTTPcodemeaning
400VALIDATION_ERRORThe body is not a JSON object, or a field is the wrong type. Every field of the run input is a string.
401UNAUTHORIZEDNo token, or an expired one: get a new one (step 2).
402INSUFFICIENT_CREDITSThe balance is below the run's minimum. Top up at skillsafe.ai/account/billing/.
403FORBIDDENA guest token tried a metered run: sign in for a personal token.
404NOT_FOUNDUnknown job id.
429RATE_LIMITEDBack off and retry with the same Idempotency-Key.

The task field: two lanes

taskwhat the model does
"audit"Detect only. Answers every flag in lane_flags (confirmed or dismissed, a clear problem or a judgment call), finds judgment-only patterns a regex cannot catch, reads the rhythm, runs the reshuffle and treadmill tests, and names what already reads like a person.
"rewrite"Rewrites the draft without the tells and without adding first person, dashes, stock hooks or invented specifics; lists the changes, what it left alone, the gaps only you can fill, and a second pass over its own rewrite.

Input fields (all strings)

fieldwhencontent
taskrequiredaudit or rewrite
draftrequiredThe draft, verbatim. Past 24,000 characters the page cuts the middle on a paragraph boundary and leaves a [tell-desk: ...] marker.
contextrequiredblog, linkedin, technical-blog, investor-email, docs or casual - the profile the facts were built with.
factsrequiredA JSON string: lane, context (profile, chosen_by, why), voice, summary (words, sentences, paragraphs, headings, protected spans, first_person, em_dashes), rhythm, rebuild_signal, vocab (the four tiers with counts), flags (uid, id, name, sev, band, tolerance, text, fix, quotes), lane_flags, protected, skipped_for_context, notes. It is Tellkit.factsOf(lane, A).
voicerewrite onlycasual, professional, technical, warm or blunt.
noteoptionalYour instruction, up to 1,500 characters.
audit_handoffrewrite onlyA JSON string of an audit reply's verdict, core_point, confirmed flag uids and its clear-problem findings, for the same draft.
retry_noteoptionalOnly on a reformat retry: what was wrong with the previous reply.

The reply contracts

Both lanes share an envelope: task, verdict (clean, patch or rebuild), context{profile, why}, headline, flag_answers[{flag, status: confirmed|dismissed, call: clear_problem|judgment_call, reason}], core_point (required for rebuild, otherwise null), questions[], summary.

task: "audit"

findings[{quote, pattern, severity: P0|P1|P2, kind: authorship|clarity|fingerprint|rhythm, call, fix}], rhythm{reading, fix}, structure_tests{reshuffle: pass|fail, treadmill: pass|fail, note}, keep[] (verbatim quotes).

task: "rewrite"

rewrite (the full text), changes[{before, after, why}], left_alone[{quote, why}], gaps[{quote, need}], second_pass[{quote, pattern, fixed}].

Worked examples

These are the page's own bundled examples (invented companies), with the input as app.js builds it and the saved model reply the page replays for free.

Audit: a monthly investor update

Input (draft and facts shortened here):

{
 "task": "audit",
 "draft": "Hi everyone,\n\nI'm thrilled to share our September update — it has been a pivotal month for Harbourline.\n\nRevenue: MRR grew to $48,200, up from $41,500 in August. This represents a significant shift in our trajectory and underscores the robust demand for our cold-chain monitoring platform. We signed 3 new logistics customers, bringing the total to 14.\n\nProduct: We shipped the new alerting engine, which seamlessly integrates with our customers' existing warehouse systems. Experts believe real-time... (1352 characters in all)",
 "context": "investor-email",
 "facts": "(a JSON string; decoded: context investor-email chosen by page, 218 words, 16 sentences (spread 0.43), 14 flags - TD-02, TD-03, TD-06, TD-07, TD-09, TD-10, TD-12, TD-16, TD-37 - lane_flags F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, F13, F14)"
}

Reply:

{
 "task": "audit",
 "verdict": "patch",
 "context": {
  "profile": "investor-email",
  "why": "A monthly update to existing investors with MRR, runway and an ask is exactly the investor-email register, so the page's choice stands and promotion, inflation and generic closers are read extra strict."
 },
 "headline": "Cut the promotional sentences wrapped around your numbers, fill in the signature, and let the MRR, runway and sales-cycle figures carry the update on their own.",
 "flag_answers": [
  {
   "flag": "F1",
   "status": "confirmed",
   "call": "judgment_call",
   "reason": "The stock sign-off repeats what the Asks section already does and adds nothing an investor needs; in an update this short, end on the ask or the signature."
  },
  {
   "flag": "F2",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "The signature is an unfilled template slot; sending it as is tells every investor the email came from a template."
  },
  {
   "flag": "F3",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "No expert, report or figure is named, and investors read an unnamed authority as padding for a market claim the update cannot back."
  },
  {
   "flag": "F4",
   "status": "confirmed",
   "call": "judgment_call",
   "reason": "One em dash in 218 words is over the density limit, and it joins the \"thrilled to share\" opener to the \"pivotal month\" label; the whole clause after it goes when the opener is replaced by the September numbers, so the dash goes with it."
  },
  {
   "flag": "F5",
   "status": "confirmed",
   "call": "clear_problem",
  
... (shortened here; the page shows it whole)

Rewrite: the same update, carrying the audit

Input (with the audit handed over):

{
 "task": "rewrite",
 "draft": "Hi everyone,\n\nI'm thrilled to share our September update — it has been a pivotal month for Harbourline.\n\nRevenue: MRR grew to $48,200, up from $41,500 in August. This represents a significant shift in our trajectory and underscores the robust demand for our cold-chain monitoring platform. We signed 3 new logistics customers, bringing the total to 14.\n\nProduct: We shipped the new alerting engine, which seamlessly integrates with our customers' existing warehouse systems. Experts believe real-time... (1352 characters in all)",
 "context": "investor-email",
 "facts": "(a JSON string; decoded: context investor-email chosen by page, 218 words, 16 sentences (spread 0.43), 14 flags - TD-02, TD-03, TD-06, TD-07, TD-09, TD-10, TD-12, TD-16, TD-37 - lane_flags F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, F13, F14)",
 "voice": "professional",
 "audit_handoff": "{\"verdict\":\"patch\",\"core_point\":null,\"confirmed\":[\"F1\",\"F2\",\"F3\",\"F4\",\"F5\",\"F6\",\"F7\",\"F8\",\"F9\",\"F10\",\"F11\",\"F12\",\"F13\",\"F14\"],\"findings\":[{\"quote\":\"remains longer than planned, averaging 94 days\",\"pattern\":\"Comparison without its baseline\",\"fix\":\"State the plan next to the actual: \\\"averaging 94 days against the N we p..."
}

Reply:

{
 "task": "rewrite",
 "verdict": "patch",
 "context": {
  "profile": "investor-email",
  "why": "A salutation, labelled metric sections and an ask to investors make this a monthly investor update, so the investor-email profile fits."
 },
 "headline": "The figures carry this update; cut the promotional sentences around them and fill in the sign-off name before sending.",
 "flag_answers": [
  {
   "flag": "F1",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "The closing offer adds nothing to an update investors can already reply to; it was cut."
  },
  {
   "flag": "F2",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "The sign-off still reads [Founder Name]; the name is not in the draft, so it is kept and listed as a gap for the writer to fill."
  },
  {
   "flag": "F3",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "No expert or study is named; the sentence was cut rather than given an invented source."
  },
  {
   "flag": "F4",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "The one em dash sat in the opening sentence, which was rewritten without it."
  },
  {
   "flag": "F5",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "\"could potentially\" stacks two hedges on a test already under way; the rewrite states the test."
  },
  {
   "flag": "F6",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "Both the market prediction and the bright-future closer make no checkable claim; both were cut."
  },
  {
   "flag": "F7",
   "status": "confirmed",
   "call": "cl
... (shortened here; the page shows it whole)

Rewrite: a blog post pasted from a chat window (rebuild)

Input:

{
 "task": "rewrite",
 "draft": "# The Hidden Cost of Meetings\n\nIn today's rapidly evolving workplace landscape, meetings have become a cornerstone of how teams collaborate. However, as organisations navigate the complexities of hybrid work, it is important to note that not all meetings are created equal. Let's delve into the intricate interplay between meeting culture and productivity.\n\nStudies show that the average employee spends a significant portion of their week in meetings. Moreover, many of these meetings lack a clear a... (1824 characters in all)",
 "context": "blog",
 "facts": "(a JSON string; decoded: context blog chosen by page, 248 words, 17 sentences (spread 0.23), 28 flags - TD-04, TD-05, TD-06, TD-07, TD-09, TD-11, TD-12, TD-13, TD-14, TD-16, TD-19, TD-28, TD-29, TD-31, TD-32, TD-34, TD-37, TD-38 - lane_flags F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12, F13, F14, F15, F16, F17, F18, F19, F20, F21, F22, F23, F24, F25, F26, F27, F28)"
}

Reply:

{
 "task": "rewrite",
 "verdict": "rebuild",
 "context": {
  "profile": "blog",
  "why": "A short opinion post with a heading and a quoted colleague; blog fits, so every rule applies at full strength."
 },
 "headline": "Cut the backdrop opener, the unsourced \"Studies show\" line and the closing paragraph, and build the post around the Tavi Orlenfeld quote.",
 "flag_answers": [
  {
   "flag": "F1",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "The citeturn0search3 token is leftover chat citation markup after the context-switching sentence; stripped."
  },
  {
   "flag": "F2",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "The report URL carried utm_source=chatgpt.com; the parameter is stripped and the rest of the URL kept."
  },
  {
   "flag": "F3",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "\"Studies show\" names no study and \"a significant portion\" gives no figure; the sentence is cut and the missing source is listed in gaps."
  },
  {
   "flag": "F4",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "\"Let's delve into\" announces a discussion instead of having it; the whole sentence is cut."
  },
  {
   "flag": "F5",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "\"workplace landscape\" is backdrop, not news; the opener now states what meetings are for teams."
  },
  {
   "flag": "F6",
   "status": "confirmed",
   "call": "clear_problem",
   "reason": "\"pivotal role\" inflates what the paragraph then says plainly; replaced with what leaders actually do."
  },
  
... (shortened here; the page shows it whole)

The page re-checks the reply; the API does not

The page holds every reply to account (recon.js): the profile, every flag answered once, fingerprints never dismissed, the verdict against what the reply confirms, every quote verbatim, no authorship claims - and a rewrite rescanned with the same free check for flags left, dashes, first person, numbers and URLs added, and code and quotations preserved byte for byte. Over the API you get the model's reply as it is; run the same checks yourself with Recon.reconcile in recon.js, or paste the same draft into the page.

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://tell-desk.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":"tell-desk"}' | 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 (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": "audit", "draft": "...", "context": "blog", "facts": "..."}
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 tell-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="tell-desk:audit:$(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}' 

Step 6 — POST /run-stream (rewrite)

The same run as Server-Sent Events. rewrite.json is a rewrite input, with audit_handoff when it follows an audit.

# 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: tell-desk:rewrite:$(shasum -a 256 rewrite.json | cut -c1-16):a1" --data @rewrite.json