← pSEO Desk / API
Tokens

Drive pSEO Desk from your own code

The page does its free work in the browser: it reads a page template with {{column}} placeholders and the data rows that fill it, renders every page, measures each page's word count, its share of template text and its nearest other page, finds duplicate URLs and titles, comparisons in both orders, empty placeholders, missing links and schema, subdomain and unslugged URLs and zero-volume rows, names the playbook and the six areas (data, template, content, technical, linking, indexation) and raises flags PS-01..PS-22. Its two paid lanes are available over HTTP. You send one JSON object with task set to "audit" or "template", and the model replies with one JSON object. The easiest way to build the facts string is the page itself: PseoKit.buildInput(lane, PseoKit.analyze(template, data, {context, playbook, question}), {auditHandoff}) in pseokit.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"The pre-launch audit: the plan (playbook, keyword pattern, a one-line read); the six areas (data, template, content, technical, linking, indexation) each good, weak, bad or unknown with a finding, quotes and a fix; the rendered pages in the page's own numbers; the issues as finding, impact and fix with the page's flag ids; an index_plan; 3 to 6 fixes; a verdict launch, fix_first or rethink.
"template"A stronger page template: the playbook and pattern; url, title, meta and h1 patterns; 4 to 8 sections with conditional content; the unique_value per page; data_needed (new columns); linking; schema; indexation.

Input fields (all strings)

fieldwhencontent
taskrequiredaudit or template
templaterequiredThe page template as pasted: header lines URL:, Title:, Meta:, H1:, Schema:, then the body after ---. May be empty for the template lane. Past 8,000 characters the page cuts the middle on line boundaries and leaves a [pseo-desk: ...] marker.
datarequiredThe data rows with a header row. Past 5,000 characters the page keeps the header and as many whole rows as fit, and says so in facts.notes.
factsrequiredA JSON string: planned, playbook, playbook_basis, pattern, columns, rows, volume_column, vars, missing_vars, unused_columns, sections, fields, links, schema, data_source, pages (rendered, words_median, boilerplate_median, near_duplicates, duplicate_urls, duplicate_titles, both_orders, empty_rows and more), volume, areas, found, flags, samples, notes.
contextoptionalThe business, the site, the goal, the data source, demand. Cut past 4,000 characters.
playbookoptionalThe playbook when the user chose it: one of templates, curation, conversions, comparisons, examples, locations, personas, integrations, glossary, translations, directory, profiles.
questionoptionalWhat the user wants to know.
audit_handofftemplate onlyA JSON string of an audit's verdict, bad[] (areas), issues[{flag, finding, fix}] and fixes[], for the same input.
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: "audit"

plan{playbook, pattern, read}, areas[{area: data|template|content|technical|linking|indexation, status: good|weak|bad|unknown, finding, evidence[], fix}] (exactly six, in order), pages{rendered, words_median, boilerplate, near_duplicates, read}, issues[{flag, kind, severity: high|medium|low, finding, impact, evidence[], fix}], index_plan{index, hold, sitemap}, fixes[] (3 to 6), verdict (launch|fix_first|rethink).

task: "template"

source (audit handoff|from the input), playbook, pattern, url, title, meta, h1, sections[{heading, purpose, content, condition}] (4 to 8), unique_value[], data_needed[{column, why, source}] (0 to 6), linking{hub, spokes, breadcrumbs}, schema, indexation{index, noindex, sitemap}. Recon.assemble(reply) in recon.js turns it into the template text the page reads.

Worked examples

These are the page's own bundled examples (invented businesses, tools 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, the audit-to-template handoff, and a second example of each.

Audit: integration pages for a scheduling SaaS, the tool name swapped (rethink)

Input (template, data and facts shortened here):

{
 "task": "audit",
 "template": "URL: /integrations/{{tool}}/\nTitle: {{tool}} integration - connect {{tool}} with Slotmarrow scheduling software | Slotmarrow\nMeta: Connect Slotmarrow to your favorite tools and automate your scheduling in minutes.\nH1: Slotmarrow + {{tool}} integration\n---\nSlotmarrow integrates with {{tool}} so your team can schedule meetings faster. Connect {{tool}} to Slotmarrow in a few clicks and keep every calendar in sync.\n\n## W... (1285 characters in all)",
 "data": "tool,category,sync_direction,what_syncs,plan,monthly_searches\nGoogle Calendar,calendar,two-way,\"Events, busy times and attendee lists\",Free,1900\nOutlook Calendar,calendar,two-way,\"Events, busy times and Teams links\",Free,880\nZoom,video conferencing,one-way,A unique Zoom link on every booking,Free,1300\nMicrosoft Teams,video conferencing,one-way,A Teams meeting link on every booking,Starter,590\nHubSpot,CRM,one-way,\"New... (898 characters in all)",
 "facts": "(a JSON string; decoded: playbook integrations, 10 rows in 6 columns, placeholders {{tool}}, {{category}}, {{setup_minutes}}, 0 conditional sections, 10 pages rendered, median 161 words, 94% template text, 10 near-duplicates, 2 duplicate URLs, 5 of 6 areas stated, flags PS-01, PS-03, PS-04, PS-05, PS-06, PS-07, PS-08, PS-10, PS-13, PS-14, PS-18, PS-20, PS-12, PS-16, PS-22)",
 "context": "Slotmarrow - meeting scheduling software for B2B sales and customer success teams (free plan, Starter $12 and Pro $25 per seat a month).\nWe want the integration pages to bring in people searching \"[tool] scheduling integration\" and convert them to free trial signups.\nThe sync details per integration (what syncs, which direction, which plan) come from our own integration specs, maintained by the product team.\nThe site... (531 characters in all)",
 "question": "Are these integration pages ready to publish?"
}

Reply:

{
 "task": "audit",
 "plan": {
  "playbook": "integrations",
  "pattern": "[tool] integration",
  "read": "One page per tool Slotmarrow connects to, for sales and customer success teams searching how to schedule meetings from the tool they already use."
 },
 "areas": [
  {
   "area": "data",
   "status": "bad",
   "finding": "The data is proprietary and strong, but Slack has no category, Zoom has two rows, and sync_direction, what_syncs and plan never reach a page.",
   "evidence": [
    "Slack,,one-way,A channel alert for every new booking and cancellation",
    "come from our own integration specs, maintained by the product team"
   ],
   "fix": "Fill the Slack category, delete the second Zoom row and add a setup_minutes column."
  },
  {
   "area": "template",
   "status": "bad",
   "finding": "The template calls {{setup_minutes}}, which has no column, and has no conditional section, so every page reads the same.",
   "evidence": [
    "Setup takes about {{setup_minutes}} minutes and no credit card is required."
   ],
   "fix": "Add the missing column and build sections on what_syncs, sync_direction and plan, with a block that differs for two-way, one-way and import tools."
  },
  {
   "area": "content",
   "status": "bad",
   "finding": "Every page is the same generic copy with the tool name swapped in; nothing says what actually syncs.",
   "evidence": [
    "you can automate your scheduling workflow, save hours every week and never miss a meeting again"
   ],
   "fix": "Replace the generic paragraph with the row's what_syncs, sync_direction and plan, plus tool-specific setup steps."
  },
  {
   "area": "technical",
   "status": "bad",
   "finding": "The URL is unslugged and collides for Zoom, the title is too long, the meta is identical on every page and there is 
... (shortened here; the page shows it whole)

Template: the same pages, carrying the audit

Input (with the audit handed over):

{
 "task": "template",
 "template": "URL: /integrations/{{tool}}/\nTitle: {{tool}} integration - connect {{tool}} with Slotmarrow scheduling software | Slotmarrow\nMeta: Connect Slotmarrow to your favorite tools and automate your scheduling in minutes.\nH1: Slotmarrow + {{tool}} integration\n---\nSlotmarrow integrates with {{tool}} so your team can schedule meetings faster. Connect {{tool}} to Slotmarrow in a few clicks and keep every calendar in sync.\n\n## W... (1285 characters in all)",
 "data": "tool,category,sync_direction,what_syncs,plan,monthly_searches\nGoogle Calendar,calendar,two-way,\"Events, busy times and attendee lists\",Free,1900\nOutlook Calendar,calendar,two-way,\"Events, busy times and Teams links\",Free,880\nZoom,video conferencing,one-way,A unique Zoom link on every booking,Free,1300\nMicrosoft Teams,video conferencing,one-way,A Teams meeting link on every booking,Starter,590\nHubSpot,CRM,one-way,\"New... (898 characters in all)",
 "facts": "(a JSON string; decoded: playbook integrations, 10 rows in 6 columns, placeholders {{tool}}, {{category}}, {{setup_minutes}}, 0 conditional sections, 10 pages rendered, median 161 words, 94% template text, 10 near-duplicates, 2 duplicate URLs, 5 of 6 areas stated, flags PS-01, PS-03, PS-04, PS-05, PS-06, PS-07, PS-08, PS-10, PS-13, PS-14, PS-18, PS-20, PS-12, PS-16, PS-22)",
 "context": "Slotmarrow - meeting scheduling software for B2B sales and customer success teams (free plan, Starter $12 and Pro $25 per seat a month).\nWe want the integration pages to bring in people searching \"[tool] scheduling integration\" and convert them to free trial signups.\nThe sync details per integration (what syncs, which direction, which plan) come from our own integration specs, maintained by the product team.\nThe site... (531 characters in all)",
 "question": "Are these integration pages ready to publish?",
 "audit_handoff": "{\"verdict\":\"rethink\",\"bad\":[\"data\",\"template\",\"content\",\"technical\",\"linking\"],\"issues\":[{\"flag\":\"PS-05\",\"finding\":\"All 10 pages share 80% or more of their three-word phrases with another page.\",\"fix\":\"Make the body come from what_syncs, sync_direction and plan, with copy written per category.\"},{\"flag\":\"PS-06\",\"findin..."
}

Reply:

{
 "task": "template",
 "source": "audit handoff",
 "playbook": "integrations",
 "pattern": "[tool] scheduling integration",
 "url": "/integrations/{{tool|slug}}/",
 "title": "{{tool}} Scheduling Integration | Slotmarrow",
 "meta": "Connect {{tool}} to Slotmarrow: {{what_syncs}}. Available on the {{plan}} plan.",
 "h1": "{{tool}} scheduling integration for Slotmarrow",
 "sections": [
  {
   "heading": "What the {{tool}} integration does",
   "purpose": "Answers the searcher's first question with this tool's own sync details instead of a generic pitch.",
   "content": "Slotmarrow connects to {{tool}} with a {{sync_direction}} sync. What syncs: {{what_syncs}}.{{#category}} It fits teams that already run their {{category}} work in {{tool}}.{{/category}}",
   "condition": "category"
  },
  {
   "heading": "What syncs between {{tool}} and Slotmarrow",
   "purpose": "Shows exactly which data moves, the detail a buyer checks before connecting a tool.",
   "content": "{{what_syncs}}. Direction: {{sync_direction}}. [One sentence from the product team on what happens when a booking changes or is cancelled in {{tool}}.]",
   "condition": "always"
  },
  {
   "heading": "Which Slotmarrow plan includes {{tool}}",
   "purpose": "Tells the searcher up front which plan they need, so the CTA does not overpromise.",
   "content": "The {{tool}} integration is available on the {{plan}} plan. [Link to the pricing page.]",
   "condition": "always"
  },
  {
   "heading": "How to connect {{tool}}",
   "purpose": "Gives setup steps specific to this tool, with the setup time only where the specs give one.",
   "content": "{{setup_steps}}{{#setup_minutes}} Setup takes about {{setup_minutes}} minutes.{{/setup_minutes}}",
   "condition": "setup_minutes"
  },
  {
   "heading": "{{tool}} integration 
... (shortened here; the page shows it whole)

Audit: a review site's tool-vs-tool pages with tested data (launch)

Input:

{
 "task": "audit",
 "template": "URL: /compare/{{tool_a|slug}}-vs-{{tool_b|slug}}/\nTitle: {{tool_a}} vs {{tool_b}}: tested side by side\nMeta: We ran the same launch project in {{tool_a}} and {{tool_b}} for two weeks. Prices, free plans, and which one fits your team.\nH1: {{tool_a}} vs {{tool_b}}\nSchema: Article, with an ItemList of the two products\n---\n[All comparisons](/compare/) > {{tool_a}} vs {{tool_b}}\n\n## The short answer\n\n{{verdict}}\n\n## What ... (1742 characters in all)",
 "data": "tool_a,tool_b,a_price,b_price,a_free_plan,b_free_plan,verdict,test_notes,best_for_a,best_for_b,migration_note,monthly_searches\nTaskorra,Cardwix,$9,$6,\"up to 10 users\",no,\"Taskorra is the better pick for teams that plan in phases with dependencies; Cardwix wins for small teams that just want cards on a board and nothing else to learn.\",\"Taskorra took 38 minutes to set up our launch project because we built the depende... (4824 characters in all)",
 "facts": "(a JSON string; decoded: playbook comparisons, 8 rows in 12 columns, placeholders {{tool_a}}, {{tool_b}}, {{verdict}}, {{test_notes}}, {{a_price}}, {{b_price}}, {{a_free_plan}}, {{b_free_plan}}, {{best_for_a}}, {{best_for_b}}, {{migration_note}}, 5 conditional sections, 8 pages rendered, median 256 words, 56% template text, 0 near-duplicates, 0 duplicate URLs, 6 of 6 areas stated, flags PS-11, PS-20)",
 "context": "Probewick Reviews - an independent review site for project management software. We make money from affiliate links.\nEvery comparison comes from our own hands-on testing: the same 40-task launch project run in both tools by the same team.\nPages target \"[tool A] vs [tool B]\" searches; monthly_searches is from our keyword tool. Hub page: /compare/. All comparison pages go in a separate comparisons sitemap.\n"
}

Reply:

{
 "task": "audit",
 "plan": {
  "playbook": "comparisons",
  "pattern": "[tool_a] vs [tool_b]",
  "read": "Head-to-head project management tool comparisons from Probewick Reviews, for buyers searching one tool against another before they pick."
 },
 "areas": [
  {
   "area": "data",
   "status": "good",
   "finding": "Every row carries proprietary test results, a verdict and fit notes, the strongest data tier there is.",
   "evidence": [
    "Every comparison comes from our own hands-on testing: the same 40-task launch project run in both tools by the same team"
   ],
   "fix": ""
  },
  {
   "area": "template",
   "status": "good",
   "finding": "The template is built from data-driven sections, with conditional free-plan and migration blocks that change between rows.",
   "evidence": [
    "{{#migration_note}}",
    "{{^a_free_plan}}{{tool_a}} has no free plan, so use its trial to test it.{{/a_free_plan}}"
   ],
   "fix": ""
  },
  {
   "area": "content",
   "status": "good",
   "finding": "Pages are substantive and distinct, but the fixed How we test paragraph is the largest block of template text on every page.",
   "evidence": [
    "Every tool gets the same 40-task product launch project, run by the same three-person team for two weeks"
   ],
   "fix": "Keep the full method on one page and link to it, leaving a one-line summary plus row-specific setup notes on each comparison."
  },
  {
   "area": "technical",
   "status": "good",
   "finding": "Clean subfolder URLs, a keyword-led title and H1, and schema are all in place.",
   "evidence": [
    "URL: /compare/{{tool_a|slug}}-vs-{{tool_b|slug}}/",
    "Schema: Article, with an ItemList of the two products"
   ],
   "fix": ""
  },
  {
   "area": "linking",
   "status": "weak",
   "finding": "Pages link to the hub a
... (shortened here; the page shows it whole)

Template: city pages for a cleaning company, from a one-line stub

Input:

{
 "task": "template",
 "template": "Title: House Cleaning in {{city}}, {{state}} | Mopsparrow\n---\nLooking for house cleaning in {{city}}? Mopsparrow offers professional house cleaning in {{city}}, {{state}}. Our trusted cleaners serve homes all across {{city}} and the surrounding area. Book your {{city}} house cleaning today!\n",
 "data": "city,state,teams_local,homes_cleaned,avg_rating,reviews,first_clean_from,top_request,serving_since,monthly_searches\nAustin,TX,6,4120,4.8,612,$149,move-out cleans,2019,2900\nRound Rock,TX,2,980,4.9,141,$139,recurring biweekly cleans,2021,480\nGeorgetown,TX,1,410,4.7,58,$139,deep cleans before listing a home,2022,260\nSan Marcos,TX,1,350,4.6,44,$129,student move-out cleans,2022,210\nPflugerville,TX,1,520,4.8,77,$139,recurr... (682 characters in all)",
 "facts": "(a JSON string; decoded: playbook locations, 10 rows in 10 columns, placeholders {{city}}, {{state}}, 0 conditional sections, 10 pages rendered, median 32 words, 84% template text, 0 near-duplicates, 0 duplicate URLs, 5 of 6 areas stated, flags PS-07, PS-21, PS-06, PS-08, PS-09, PS-14, PS-16, PS-22)",
 "context": "Mopsparrow Cleaning - a residential cleaning company in the Austin, Texas area. Book online; flat prices by home size.\nWe want a city page for every place we serve or are about to serve. The numbers per city come from our booking system (jobs done, reviews left by our customers).\nKyle and Buda open next month: no team or bookings there yet.\nGoal: online bookings. Site: mopsparrowclean.example, local backlinks only.\n",
 "playbook": "locations",
 "question": "How do we make each city page worth indexing?"
}

Reply:

{
 "task": "template",
 "source": "from the input",
 "playbook": "locations",
 "pattern": "[house cleaning] in [city]",
 "url": "/house-cleaning/{{state|slug}}/{{city|slug}}/",
 "title": "House Cleaning in {{city}}, {{state}} | Mopsparrow",
 "meta": "Mopsparrow house cleaning in {{city}}, {{state}}. {{#avg_rating}}Rated {{avg_rating}} from {{reviews}} reviews. {{/avg_rating}}First clean from {{first_clean_from}}. Book online.",
 "h1": "House Cleaning in {{city}}, {{state}}",
 "sections": [
  {
   "heading": "Mopsparrow in {{city}}",
   "purpose": "Opens with this city's own booking record so the page proves local service instead of repeating the city name.",
   "content": "{{#serving_since}}Mopsparrow has cleaned {{homes_cleaned}} homes in {{city}} since {{serving_since}}, with {{teams_local}} local cleaning teams.{{/serving_since}}{{^serving_since}}Mopsparrow is opening in {{city}} soon. Join the list to book one of the first cleans.{{/serving_since}}",
   "condition": "serving_since"
  },
  {
   "heading": "What {{city}} customers book most",
   "purpose": "Answers what local people actually need, using the most requested job type from the booking system.",
   "content": "{{#top_request}}The most booked job in {{city}} is {{top_request}}. [One or two sentences on how that clean works and what it includes.]{{/top_request}}",
   "condition": "top_request"
  },
  {
   "heading": "Ratings from {{city}} homes",
   "purpose": "Shows proof from real local customers, which a searcher weighs before booking.",
   "content": "{{#avg_rating}}{{city}} customers rate Mopsparrow {{avg_rating}} out of 5 across {{reviews}} reviews. [Two short quotes from {{city}} reviews.]{{/avg_rating}}",
   "condition": "avg_rating"
  },
  {
   "heading": "Prices in {{city}}",
   "purpose": "Gives t
... (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 an audit, the playbook you chose; six areas in order, none called good under a high or medium flag and every high flag's area called bad, nothing claimed for an area the paste never states without a quote; the rendered pages, median words, template text and near-duplicates equal to the page's own render; every high and medium flag carried and a high one never downgraded; rethink when two high flags stand or three areas are bad, and no "launch" while a high flag stands. For a template, the playbook you chose; a subfolder URL with slugged placeholders; a title, meta and H1 with placeholders; every placeholder a column in the data or one listed in data_needed; 4 to 8 sections with at least one conditional section, and a template that parses; unique value from at least two columns; a hub, spokes, schema and a noindex rule; the handed-off audit answered; and the page renders the new template over the data. In both, quotes are checked word for word and no count, price, volume or ranking 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://pseo-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":"pseo-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", "template": "...", "data": "...", "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 pseo-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.

KEY="pseo-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 (template)

The same run as Server-Sent Events. template.json is a template-lane 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: pseo-desk:template:$(shasum -a 256 template.json | cut -c1-16):a1" --data @template.json