← Content Desk / API
Tokens

Drive Content Desk from your own code

The page does its free work in the browser: it reads your keyword export (Ahrefs, Semrush, Search Console with impressions standing in for volume, Keyword Planner ranges read as their lower bound, or a plain list), your description and your customer research, and works out every keyword's buyer stage from the skill's modifiers and its intent, topic clusters by shared term, quick-win candidates (difficulty 30 or under, or low competition, at or above the median volume), content gaps (no position or below 20), which clusters the research mentions, your publishing capacity and the 60/30/10 calendar split, with flags CS-01..CS-12. Its two paid lanes are available over HTTP. You send one JSON object with task set to "plan" or "piece", and the model replies with one JSON object. The easiest way to build the facts string is the page itself: ContentKit.buildInput(lane, ContentKit.analyze(you, keywords, research, {company, goal, question, topic}), {planHandoff}) in contentkit.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
"plan"The content strategy: the goal, 3-5 pillars (product-led, audience-led, search-led or competitor-led, quoted, with the page's cluster ids), 5-10 topics scored 1-10 on customer impact, content-market fit, search potential and resources with a total weighted 40/30/20/10, up to 5 quick_wins from the page's candidates, the calendar split and the cluster_map.
"piece"One flagship piece: ten title_options and the title, the kind, content_type, keyword_id and keyword, buyer_stage, intent, slug, angle, first_line, a 4-8 section outline, evidence_needed, a link_note, the five editing_passes, distribution{owned, rented, borrowed} and lines to atomize.

Input fields (all strings)

fieldwhencontent
taskrequiredplan or piece
companyrequiredYour company's name (may be empty; the page then reads it from the first line of you).
goaloptionalWhat content is for: traffic, leads, awareness or authority.
yourequiredWhat your company does: what you sell and who buys, the content you have, who writes and how much you publish, formats, competitors. Past 5,000 characters the page cuts the middle on line boundaries and leaves a [content-desk: ...] marker.
factsrequiredA JSON string: company, goal, today, export{rows_read, rows_sent, header, columns[], volume_basis, total_volume, median_volume, dropped, duplicates}, category_terms[], keywords[{id, keyword, volume, stage, intent, type_hint, cluster, difficulty?, competition?, position?, research?}] (up to 150 rows: the highest-volume ones plus every quick win and gap), clusters[{id, term, count, volume, stages, keyword_ids, research, research_share, theme}], stage_count, stage_volume, quick_wins[], gaps[], gap_count, research[{id, kind, source, text}], you{product, pieces_month, writers, formats, competitors}, calendar{pieces_month, searchable, shareable, experimental}, flags[], notes[].
questionoptionalWhat you want to know.
topicpiece onlyThe piece to plan.
plan_handoffpiece, optionalA JSON string of the plan topic the piece follows: title, pillar, kind, content_type, keyword_id, keyword, buyer_stage, research_ids[].
retry_noteoptionalOnly on a reformat retry: what was wrong with the previous reply.

The reply contracts

Both lanes share an envelope: task, headline, questions[], summary.

task: "plan"

goal, pillars[{id, name, basis, why, product_link, clusters[], subtopics[], evidence[]}] (3 to 5), topics[{title, pillar, kind: searchable|shareable|both, content_type, keyword_id, keyword, buyer_stage, why, research_ids[], scores{customer_impact, content_market_fit, search_potential, resources}, total}] (5 to 10, highest total first), quick_wins[{keyword_id, why}] (0 to 5), calendar{searchable, shareable, experimental, note} (pieces a month, or percent when no capacity was stated), cluster_map[{pillar, hub, spokes[]}].

task: "piece"

source (plan handoff|from the input), title_options[] (exactly 10), title, kind, content_type, keyword_id, keyword, buyer_stage, intent: informational|commercial|transactional, slug, angle, first_line, outline[{heading, point, research_ids[]}] (4 to 8), evidence_needed[], link_note, editing_passes (structure, clarity, evidence, line edit, headline/SEO), distribution{owned[], rented[], borrowed[]}, atomize[].

Worked examples

These are the page's own bundled examples (invented companies, people and numbers), with the input as app.js builds it and the saved model reply the page replays for free. One worked example per lane, two plans with different exports, and the plan-to-piece handoff.

Plan: invoicing for design agencies, 28 Ahrefs keywords and 10 customer notes

Input (description and facts shortened here):

{
 "task": "plan",
 "company": "Brindlebill",
 "you": "Brindlebill - invoicing and time tracking for design and branding agencies with 5-40 people.\nOur buyer is the agency owner or the studio manager; the champion is often the producer who chases late invoices.\nWe sell on a 14-day free trial, then $29 per seat a month.\nContent today: a blog with 22 posts, mostly product updates. Nothing ranks except our brand name.\nTeam: one freelance writer and our head of marketing (On... (637 characters in all)",
 "facts": "(a JSON string; decoded: company Brindlebill, goal leads, 28 keyword rows read and 28 sent (columns keyword, volume, difficulty, position, url), total 111260 volume, median 940, 8 clusters, quick wins K9, K2, K8, K5, K21, K27, K3, K11, K23, K10, 10 research items, 4 pieces a month, calendar 2/1/1; flags CS-05)",
 "goal": "leads",
 "question": "What should Ondrelle and our freelancer write first?"
}

Reply:

{
 "task": "plan",
 "goal": "leads",
 "pillars": [
  {
   "id": "P1",
   "name": "Getting paid on time",
   "basis": "product-led",
   "why": "Late payments are the pain customers name most, and invoicing is what Brindlebill sells.",
   "product_link": "Every piece on invoicing and chasing payment ends at Brindlebill's invoicing and its free trial.",
   "clusters": [
    "C1",
    "C2"
   ],
   "subtopics": [
    "late payment emails",
    "chasing overdue clients",
    "invoicing retainers",
    "design invoice templates"
   ],
   "evidence": [
    "Late payments wreck our cash flow every quarter",
    "the champion is often the producer who chases late invoices"
   ]
  },
  {
   "id": "P2",
   "name": "Scope and pricing",
   "basis": "audience-led",
   "why": "Survey and sales-call answers keep returning to scope creep, revisions and day rates.",
   "product_link": "Tracking hours against a fixed fee shows when scope creeps, which leads to Brindlebill's time tracking.",
   "clusters": [
    "C4",
    "C6"
   ],
   "subtopics": [
    "scope creep on fixed-fee projects",
    "change orders",
    "pricing design projects",
    "charging for revisions"
   ],
   "evidence": [
    "Our biggest headache is scope creep on fixed-fee projects",
    "I never know if our day rate is right for branding work"
   ]
  },
  {
   "id": "P3",
   "name": "Billable hours and profitability",
   "basis": "product-led",
   "why": "Clients ask for hour breakdowns and owners want to see which projects made money.",
   "product_link": "Time tracking feeding the invoice is the core of what Brindlebill does.",
   "clusters": [
    "C5"
   ],
   "subtopics": [
    "tracking billable hours",
    "hours breakdowns for clients",
    "project profitability"
   ],
   "evidence": [
    "Clients keep asking for a breakdown of billable hours before they pay",
    "Can I see which projects actually made money after the hours are in?"
   ]
  },
  {
   "id": "P4",
   "name": "Choosing agency invoicing software",
   "basis": "competitor-led",
   "why": "Consideration and decision keywords name competitors and agency software, closest to a trial signup.",
   "product_link": "Comparison and software pages send agency owners straight to the 14-day free trial.",
   "clusters": [
    "C3",
    "C7",
    "C8"
   ],
   "subtopics": [
    "Quillvoy alternatives",
    "Tallowfen vs Quillvoy",
    "invoicin
... (shortened here; the page shows it whole)

Piece: the plan's first topic, handed over

Input (with the plan topic handed over):

{
 "task": "piece",
 "company": "Brindlebill",
 "you": "Brindlebill - invoicing and time tracking for design and branding agencies with 5-40 people.\nOur buyer is the agency owner or the studio manager; the champion is often the producer who chases late invoices.\nWe sell on a 14-day free trial, then $29 per seat a month.\nContent today: a blog with 22 posts, mostly product updates. Nothing ranks except our brand name.\nTeam: one freelance writer and our head of marketing (On... (637 characters in all)",
 "facts": "(a JSON string; decoded: company Brindlebill, goal leads, 28 keyword rows read and 28 sent (columns keyword, volume, difficulty, position, url), total 111260 volume, median 940, 8 clusters, quick wins K9, K2, K8, K5, K21, K27, K3, K11, K23, K10, 10 research items, 4 pieces a month, calendar 2/1/1; flags CS-05)",
 "goal": "leads",
 "question": "What should Ondrelle and our freelancer write first?",
 "topic": "Late payment email template for design agencies",
 "plan_handoff": "{\"title\":\"Late payment email template for design agencies\",\"pillar\":\"P1\",\"kind\":\"both\",\"content_type\":\"template library\",\"keyword_id\":\"K9\",\"keyword\":\"late payment email template\",\"buyer_stage\":\"implementation\",\"research_ids\":[\"R9\",\"R2\"]}"
}

Reply:

{
 "task": "piece",
 "source": "plan handoff",
 "title_options": [
  "Late payment email template for design agencies",
  "The late payment email template agencies can send without sounding petty",
  "Late payment email template: polite reminders that get agency invoices paid",
  "A late payment email template for every stage of an overdue invoice",
  "Late payment email template for studios: from friendly nudge to final notice",
  "Copy this late payment email template before your next client pays late",
  "Late payment email template for branding agencies and design studios",
  "How to chase an overdue invoice: a late payment email template for agencies",
  "The polite late payment email template for creative agencies",
  "Late payment email template plus when to send each reminder"
 ],
 "title": "Late payment email template for design agencies",
 "kind": "both",
 "content_type": "template library",
 "keyword_id": "K9",
 "keyword": "late payment email template",
 "buyer_stage": "implementation",
 "intent": "informational",
 "slug": "/blog/late-payment-email-template",
 "angle": "Ready-to-copy reminder emails for each stage of an overdue invoice, written for the agency producer who has to stay on good terms with the client.",
 "first_line": "This late payment email template gives your producer a polite, ready-to-send message for every stage of an overdue invoice.",
 "outline": [
  {
   "heading": "Why late payments hurt design agencies",
   "point": "Open with the cash flow pain agencies describe and the time lost chasing clients.",
   "research_ids": [
    "R9",
    "R2"
   ]
  },
  {
   "heading": "Late payment email template: the friendly first reminder",
   "point": "A short, warm note sent just after the due date that assumes the client simply missed it.",
   "research_ids": [
    "R2"
   ]
  },
  {
   "heading": "Late payment email template: the firm second reminder",
   "point": "A clearer note that restates the invoice, the amount owed and the agreed terms.",
   "research_ids": []
  },
  {
   "heading": "Late payment email template: the final notice",
   "point": "A final message that names next steps, such as pausing work or applying a late fee.",
   "research_ids": [
    "R9"
   ]
  },
  {
   "heading": "How to add a late payment fee to the invoice",
   "point": "Show how to state a late fee on the invoice itself so the reminders have something to
... (shortened here; the page shows it whole)

Plan: pottery-studio software from a Search Console export, no writer

Input:

{
 "task": "plan",
 "company": "Glazewhorl",
 "you": "Glazewhorl - booking and membership software for pottery studios and community ceramics spaces.\nWho buys: studio owners running classes, open studio memberships and kiln firing services.\nWhat we solve: double-booked wheels, members who forget to pay, and firing queues kept on a whiteboard.\nWe have no dedicated writer; the founder writes when she can.\nCompetitors: none that focus on ceramics; studios use generic class... (436 characters in all)",
 "facts": "(a JSON string; decoded: company Glazewhorl, goal traffic, 13 keyword rows read and 13 sent (columns keyword, impressions, position), total 12400 impressions, median 720, 1 clusters, quick wins none, 2 research items, None pieces a month, calendar None/None/None; flags CS-04, CS-08)",
 "goal": "traffic"
}

Reply:

{
 "task": "plan",
 "goal": "traffic",
 "pillars": [
  {
   "id": "P1",
   "name": "Kiln firing operations",
   "basis": "product-led",
   "why": "Firing queues and firing fees are a core problem Glazewhorl solves, and the kiln cluster carries search demand.",
   "product_link": "Readers who manage firing queues by hand are the studios Glazewhorl replaces the whiteboard for.",
   "clusters": [
    "C1"
   ],
   "subtopics": [
    "firing fees for member work",
    "firing schedules",
    "choosing a kiln",
    "managing the firing queue"
   ],
   "evidence": [
    "firing queues kept on a whiteboard",
    "How much should I charge for firing a member's bisque ware?"
   ]
  },
  {
   "id": "P2",
   "name": "Open studio memberships",
   "basis": "audience-led",
   "why": "Studio owners report no-show wheel bookings and members who forget to pay.",
   "product_link": "Membership rules and pricing lead straight to Glazewhorl membership billing and wheel booking.",
   "clusters": [],
   "subtopics": [
    "open studio rules",
    "wheel booking no-shows",
    "membership pricing",
    "late member payments"
   ],
   "evidence": [
    "Members book the wheel and don't show up",
    "members who forget to pay"
   ]
  },
  {
   "id": "P3",
   "name": "Starting and running a studio",
   "basis": "search-led",
   "why": "The largest search demand in the export is from people starting or running a studio.",
   "product_link": "New studio owners set up classes and memberships early, when they choose booking software.",
   "clusters": [],
   "subtopics": [
    "starting a studio",
    "class pricing",
    "business plan",
    "studio insurance"
   ],
   "evidence": [
    "studio owners running classes, open studio memberships and kiln firing services"
   ]
  }
 ],
 "topics": [
  {
   "title": "Kiln firing fee: how much to charge for firing member work",
   "pillar": "P1",
   "kind": "searchable",
   "content_type": "how-to",
   "keyword_id": "K6",
   "keyword": "kiln firing fee",
   "buyer_stage": "awareness",
   "why": "A studio owner asked directly how much to charge for firing a member's bisque ware.",
   "research_ids": [
    "R2"
   ],
   "scores": {
    "customer_impact": 9,
    "content_market_fit": 8,
    "search_potential": 5,
    "resources": 8
   },
   "total": 7.8
  },
  {
   "title": "Open studio rules for members that stop wheel no-shows",
   "pillar": "P2"
... (shortened here; the page shows it whole)

Piece: HVAC dispatch software, a typed topic and Keyword Planner ranges

Input:

{
 "task": "piece",
 "company": "Ventquarry",
 "you": "Ventquarry - scheduling and dispatch software for HVAC and plumbing contractors with 3-30 technicians.\nBuyer: the owner or the office manager who builds the schedule every morning.\nWe publish 2 articles a month, written by our in-house content marketer, Dev.\nCompetitors: Dispatchwold, Wrenchloft\nProof we can use: our own data from 1,140 contractors shows the average tech drives 41 miles a day.\n",
 "facts": "(a JSON string; decoded: company Ventquarry, goal leads, 6 keyword rows read and 6 sent (columns keyword, volume, competition), total 2310 volume, median 100, 1 clusters, quick wins K2, K3, 3 research items, 2 pieces a month, calendar 1/1/0; flags CS-11, CS-12)",
 "goal": "leads",
 "topic": "How to schedule HVAC technicians in peak season"
}

Reply:

{
 "task": "piece",
 "source": "from the input",
 "title_options": [
  "How to Schedule HVAC Technicians in Peak Season",
  "How to Schedule HVAC Technicians When the Board Is Full by 8am",
  "How to Schedule HVAC Technicians for Emergency Calls in Peak Season",
  "How to Schedule HVAC Technicians Without Rebuilding the Whole Board",
  "How to Schedule HVAC Technicians: A Peak Season Playbook for Office Managers",
  "Peak Season Dispatch: How to Schedule HVAC Technicians Without Turning Away Calls",
  "Which Tech Gets the Emergency Call? How to Schedule HVAC Technicians in Summer",
  "How to Schedule HVAC Technicians When One Job Runs Long",
  "The Office Manager's Guide to Peak Season HVAC Scheduling",
  "Stop Turning Away July Calls: Peak Season Scheduling for HVAC Shops"
 ],
 "title": "How to Schedule HVAC Technicians in Peak Season",
 "kind": "both",
 "content_type": "how-to",
 "keyword_id": "K2",
 "keyword": "how to schedule hvac technicians",
 "buyer_stage": "awareness",
 "intent": "informational",
 "slug": "/blog/how-to-schedule-hvac-technicians",
 "angle": "A step-by-step method an owner or office manager can use each morning to fit more peak season calls, route emergencies and absorb a job that runs long, backed by Ventquarry's own drive-time data.",
 "first_line": "If you have ever turned away a July call because the board was full by 8am, here is how to schedule HVAC technicians so peak season stops beating your schedule.",
 "outline": [
  {
   "heading": "Why peak season breaks the morning schedule",
   "point": "Name the problem buyers describe: the schedule fills early and calls get turned away.",
   "research_ids": [
    "R1"
   ]
  },
  {
   "heading": "How to schedule HVAC technicians each morning",
   "point": "Walk through building the day's board step by step, from open jobs to tech assignments.",
   "research_ids": []
  },
  {
   "heading": "How to decide which tech gets the emergency call",
   "point": "Give clear rules for routing emergency calls by skill, location and current load.",
   "research_ids": [
    "R2"
   ]
  },
  {
   "heading": "What to do when one job runs long",
   "point": "Show how to adjust only the affected slots instead of rebuilding the whole board.",
   "research_ids": [
    "R3"
   ]
  },
  {
   "heading": "Cutting drive time between jobs",
   "point": "Use Ventquarry's data on daily miles per tech to show why g
... (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): for a plan, the goal you chose, 3 to 5 pillars from the skill's four bases with quotes and only the page's cluster ids (none in two pillars), 5 to 10 topics sorted by total, each total exactly 40/30/20/10 of whole-number 1-10 scores, customer impact 7 or more only with research behind it and 5 or less without any research, search potential 3 or less with no keyword, every keyword id one of the page's rows with its exact keyword and stage, quick wins only from the page's candidates, a calendar that adds up to the capacity (or 100%), and a cluster map with every pillar and topic. For a piece, exactly ten distinct titles with the pick among them and no year, the keyword in the title, first line and slug, the row's stage and intent, 4 to 8 sections, the five editing passes and an owned channel. In both, quotes are checked word for word and no volume, count, percentage, date or duration may be invented. 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 input 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://content-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":"content-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": "plan", "company": "...", "goal": "leads", "you": "...", "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 content-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="content-desk:plan:$(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 (piece)

The same run as Server-Sent Events. piece.json is a piece-lane input, with plan_handoff when it follows a plan topic.

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