← Launch Proof
API
Tokens

Run Launch Proof from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads what the site serves (the response headers, the HTML head, robots.txt as crawlers read it, the sitemap, llms.txt and the manifest), runs every check of the skill's phases 4 to 7, and settles the AI-crawler policy for the site type, then hands the rows to the run as facts. The run writes the report or the files; the page then holds it to its own rows (launchreply.js): every fail row a blocker and every warn row a fix, each phase's status following from them, blockers ordered before fixes; titles of 50-60 and descriptions of 150-160 characters, llms.txt in its shape, Organization and WebSite JSON-LD, and no URL the site does not have.

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":"launch-proof"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required key (task, site, served, facts) is missing. The page always sends all five of task, site, served, carry, facts; carry is an empty string for report.
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 lanes are metered and need 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

Two lanes, chosen by task, in the order a launch runs - the verdict, then the fix:

A missing or unknown task is answered as report. Both lanes send the same five fields. The facts are the page's checks, one row per line, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the report's blockers and fixes into carry for the files lane.

The input

FieldRequiredWhat
taskyesreport or files. If it is missing or neither, do report and set "task": "report".
siteyesthe site's address, its type, the host, the CSP level, the languages, the brand, what the site is, the social handle and other pages; "not given" means not given.
servedyeswhat the site serves, as pasted: the response headers, the HTML head, robots.txt, sitemap.xml, llms.txt and manifest.json, each under its own heading; "(not pasted)" means not pasted.
carrysend it (empty for report)files task only: the report's blockers and fixes, when the report ran first; may be empty.
factsyesthe page's checks, one line each as phase N | id | status | evidence, the AI-crawler policy, and for the files task the URLs that exist and the pages to write for. A line starting "must:" applies to your reply.
retry_notenooptional; if present, your previous reply was unusable - follow it.

The rules the model applies

From the source skill, @samber/site-launch-checklist - the only standard the run applies:

House rules

The replies

report

Write the launch report, grouped by phase.

  1. summary - one sentence, at most 40 words: whether the site can launch and the one thing to fix first.
  2. phases - exactly four objects, phases 4, 5, 6 and 7 in order: phase (the number), status (blocked when the phase has a fail row, fixes when it has warn rows and no fail, clean when every row passes, unchecked when every row is n/a), blockers (one object per fail row of the phase: id - the row's id exactly; fix - one or two sentences naming the exact header, file or tag and its value), fixes (the same for every warn row), manual (the checks the page cannot run for this phase, as short strings).
  3. order - every id once: first all the blockers, across phases 4 to 7, then all the fixes; within each group, the order to do them (the most damaging first).
  4. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "report",
  "summary": "one sentence",
  "phases": [ { "phase": 4, "status": "blocked", "blockers": [ { "id": "csp", "fix": "one or two sentences" } ], "fixes": [ { "id": "hsts", "fix": "one or two sentences" } ], "manual": [ "a check" ] } ],
  "order": [ "csp", "hsts" ],
  "caveats": [ ]
}

The page holds it to its own rows (check() in launchreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL outside the site's own and the named checking tools; exactly phases 4, 5, 6 and 7 in order; in each phase the blockers are exactly the fail rows and the fixes exactly the warn rows, in the facts' order; each phase's status follows from them (blocked, fixes, clean, unchecked); every blocker and fix has its fix; the order lists every blocker and fix once, blockers first. A phase with no manual check is a note that does not flag.

files

Write the files that need words: llms.txt, the title and description of each page, the social preview text and the JSON-LD.

  1. summary - one sentence, at most 40 words.
  2. llms_txt - the whole llms.txt: an H1 with the site name, a one-paragraph summary as a "> " blockquote, then "## " sections listing pages as "- [Name](URL): what it is", using only URLs that exist.
  3. meta - one object per page the facts list, in that order: path, title (50-60 characters), description (150-160 characters). Count the characters.
  4. og - title (at most 60 characters), description (at most 160 characters) and image_alt for the home page's social card.
  5. jsonld - a list of JSON-LD objects for the home page, each with "@context": "https://schema.org": an Organization (or a Person for a personal site) and a WebSite, plus a SoftwareApplication when the site type is a SaaS app or a doc site for a library. Use only URLs that exist; add sameAs only for a social handle the site gives.
  6. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "files",
  "summary": "one sentence",
  "llms_txt": "# Name\n\n> One paragraph.\n\n## Docs\n\n- [Page](https://example.com/page): what it is",
  "meta": [ { "path": "/", "title": "50-60 characters", "description": "150-160 characters" } ],
  "og": { "title": "a title", "description": "a description", "image_alt": "what the image shows" },
  "jsonld": [ { "@context": "https://schema.org", "@type": "Organization", "name": "Name", "url": "https://example.com" } ],
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; no URL the site does not have; llms.txt with an H1, a summary blockquote, a section and at least one link; one metadata entry per page the facts list, in that order, each title 50-60 characters and each description 150-160; a social card with a title, a description and the image's alt text; JSON-LD objects with a schema.org @context, an Organization or a Person and a WebSite, plus a SoftwareApplication for a SaaS app or a doc site; and no sameAs when the site gives no social handle. A relative link in llms.txt is a note that does not flag.

Worked examples

Three runs from the page's free samples (invented sites on the reserved .example domain): the report for a library's doc site, the files for that site carrying the report's to-do, and the report for a product's marketing site with a staging noindex left in. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the served text, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.

report - A library's doc site

The page read it as a doc site or library homepage: launch blockers (4 fail, 5 warn, 16 pass). Fail rows: csp, ai, llms, twitter. Warn rows: hsts, jsonld, title, ogimage, manifest. AI crawlers: a doc site wants to be cited: allow the AI crawlers, block the low-value scrapers.

Input (served is 2957 characters; the first 1395 are shown; facts is 2871 characters; the first 1394 are shown):

{
 "task": "report",
 "site": "Site: https://quarlo.example\nSite type: Doc site or library homepage\nHost: Cloudflare Pages (_headers)\nCSP level: strict-default-src-none\nLanguages: single locale (en)\nBrand: Quarlo\nWhat the site is: Quarlo is an open-source TypeScript library for typed SQL query building; the site holds the guide, the API reference and the changelog.\nTwitter / X handle: @quarlolib\nOther pages:\n- /guide/getting-started | Getting started | Install Quarlo and write your first typed query.\n- /api | API reference",
 "served": "=== Response headers ===\nHTTP/2 200\ncontent-type: text/html; charset=utf-8\nserver: cloudflare\ncontent-security-policy: default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'\nstrict-transport-security: max-age=31536000; includeSubDomains\nx-content-type-options: nosniff\nx-frame-options: DENY\nreferrer-policy: strict-origin-when-cross-origin\npermissions-policy: camera=(), microphone=(), geolocation=(), payment=()\n\n=== HTML head ===\n<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Quarlo - typed SQL</title>\n<meta name=\"description\" content=\"Quarlo is a TypeScript query builder that checks every SQL query against your schema at compile time, so a renamed column fails the build, not production.\">\n<link rel=\"canonical\" href=\"https://quarlo.example/\">\n<meta property=\"og:title\" content=\"Quarlo - typed SQL for TypeScript\">\n<meta property=\"og:description\" content=\"Every SQL query checked against your schema at compile time.\">\n<meta property=\"og:url\" content=\"https://quarlo.example/\">\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:site_name\" content=\"Quarlo\">\n<meta property=\"og:image\" content=\"https://quarlo.example/og.png\">\n<meta name=\"twitter:card\" content=\"summary\">\n<meta name=\"twitter:site\" content=\"@quarlolib\">\n\n[... 1562 more characters cut from this display ...]",
 "carry": "",
 "facts": "The page's checks (4 fail, 5 warn, 16 pass, 0 not checkable from what was pasted):\n- phase 4 | csp | fail | 'unsafe-inline' in script-src - use nonces\n- phase 4 | hsts | warn | \"max-age=31536000; includeSubDomains\" - no preload\n- phase 4 | xfo | pass | \"DENY\"\n- phase 4 | nosniff | pass | \"nosniff\"\n- phase 4 | referrer | pass | \"strict-origin-when-cross-origin\"\n- phase 4 | permissions | pass | denies all four\n- phase 4 | fingerprint | pass | none\n- phase 4 | secrets | pass | none in what was pasted (check the JS bundle too)\n- phase 5 | robots | pass | Sitemap: https://quarlo.example/sitemap.xml\n- phase 5 | indexable | pass | Googlebot allowed (via the * group)\n- phase 5 | ai | fail | GPTBot is blocked via its own group, policy says allow; Bytespider is allowed via the * group, policy says block; Amazonbot is allowed via the * group, policy says block\n- phase 5 | sitemap | pass | 4 URLs, 4 lastmod\n- phase 5 | llms | fail | not pasted - paste /llms.txt\n- phase 5 | jsonld | warn | types: SoftwareApplication - add Organization and WebSite\n- phase 5 | title | warn | 18 characters: \"Quarlo - typed SQL\"\n- phase 5 | description | pass | 154 characters\n- phase 5 | canonical | pass | https://quarlo.example/\n- phase 5 | noindex | pass | indexable\n- phase 6 | og | pass | all five\n- phase 6 | ogimage | warn | https://quarlo.example/og.png - no width/height - no og:image:alt\n- phase 6 |\n\n[... 1477 more characters cut from this display ...]"
}

Reply:

{
 "task": "report",
 "summary": "Not ready to launch: four blockers remain, and the first to fix is the Content-Security-Policy, whose script-src still allows 'unsafe-inline' for the inline theme script.",
 "phases": [
  {
   "phase": 4,
   "status": "blocked",
   "blockers": [
    {
     "id": "csp",
     "fix": "Move the inline theme script in the head into a file under /assets/ (or give it a nonce from a Pages Function), then drop 'unsafe-inline' so _headers sends script-src 'self' with the rest of the default-src 'none' policy unchanged."
    }
   ],
   "fixes": [
    {
     "id": "hsts",
     "fix": "Change the Strict-Transport-Security line in _headers to max-age=31536000; includeSubDomains; preload, then submit quarlo.example at hstspreload.org."
    }
   ],
   "manual": [
    "securityheaders.com grade A+",
    "observatory.mozilla.org score 90 or more",
    "No secrets in the JS bundle (search the Network tab for sk_, pk_, AKIA, ghp_, Bearer)",
    "HSTS submitted to hstspreload.org"
   ]
  },
  {
   "phase": 5,
   "status": "blocked",
   "blockers": [
    {
     "id": "ai",
     "fix": "In robots.txt delete the GPTBot group that sends Disallow: /, so GPTBot falls back to the * group's Allow: /, and add a group with User-agent: Bytespider and User-agent: Amazonbot followed by Disallow: /."
    },
    {
     "id": "llms",
     "fix": "Publish /llms.txt with an H1 of Quarlo, a one-paragraph > summary and a ## Docs section linking the getting-started guide, the API reference and the changelog, then paste it here to re-check."
    }
   ],
   "fixes": [
    {
     "id": "jsonld",
     "fix": "Keep the SoftwareApplication block and add an Organization (name Quarlo, url https://quarlo.example/) and a WebSite (name Quarlo, url https://quarlo.example/) as JSON-LD in the head."
    },
    {
     "id": "title",
     "fix": "The title tag is 18 characters; lengthen it to 50-60, for example <title>Quarlo - type-safe SQL query builder for TypeScript</title> (51 characters)."
    }
   ],
   "manual": [
    "Google Rich Results Test on the JSON-LD",
    "Keyword shortlist per page (Google Trends for direction, Ahrefs for size)",
    "AI visibility check of how the site appears in AI answers",
    "Typo and grammar pass",
    "Every important page within three clicks of the home page"
   ]
  },
  {
   "phase": 6,
   "status": "blocked",
   "blockers": [
    {
     "id": "twitter",
     "fix": "Change twitter:card from summary to summary_large_image and add twitter:title, twitter:description and twitter:image pointing at https://quarlo.example/og.png, keeping twitter:site @quarlolib."
    }
   ],
   "fixes": [
    {
     "id": "ogimage",
     "fix": "Declare og:image:width 1200 and og:image:height 630 next to og:image (after confirming og.png is 1200 by 630), and add an og:image:alt that describes what the image shows."
    }
   ],
   "manual": [
    "opengraph.xyz preview (Facebook, LinkedIn, Slack, Discord, WhatsApp)",
    "Paste the URL in a Slack channel and a LinkedIn DM"
   ]
  },
  {
   "phase": 7,
   "status": "fixes",
   "blockers": [],
   "fixes": [
    {
     "id": "manifest",
     "fix": "Add \"short_name\": \"Quarlo\" to /site.webmanifest; name, display, theme_color, background_color and the 192 and 512 icons are already there."
    }
   ],
   "manual": [
    "/favicon.ico served as multi-resolution 16/32/48",
    "apple-touch-icon is 180x180 with an opaque background"
   ]
  }
 ],
 "order": [
  "csp",
  "ai",
  "llms",
  "twitter",
  "hsts",
  "title",
  "jsonld",
  "ogimage",
  "manifest"
 ],
 "caveats": [
  "llms.txt was not pasted; if it already exists, paste it and the llms row re-checks.",
  "favicon.ico is not linked in the head; confirm /favicon.ico is served."
 ]
}

What step 5 prints for it: 1. csp | 2. ai | 3. llms | 4. twitter | 5. hsts | 6. title | 7. jsonld | 8. ogimage | 9. manifest

The page's check: kept - nothing to flag

files - A library's doc site

The input carries the saved report's blockers and fixes (R.laneForm fills carry from the report reply), as the page's handoff does.

Input (served is 2957 characters; the first 1395 are shown; facts is 2520 characters; the first 1394 are shown):

{
 "task": "files",
 "site": "Site: https://quarlo.example\nSite type: Doc site or library homepage\nHost: Cloudflare Pages (_headers)\nCSP level: strict-default-src-none\nLanguages: single locale (en)\nBrand: Quarlo\nWhat the site is: Quarlo is an open-source TypeScript library for typed SQL query building; the site holds the guide, the API reference and the changelog.\nTwitter / X handle: @quarlolib\nOther pages:\n- /guide/getting-started | Getting started | Install Quarlo and write your first typed query.\n- /api | API reference",
 "served": "=== Response headers ===\nHTTP/2 200\ncontent-type: text/html; charset=utf-8\nserver: cloudflare\ncontent-security-policy: default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'\nstrict-transport-security: max-age=31536000; includeSubDomains\nx-content-type-options: nosniff\nx-frame-options: DENY\nreferrer-policy: strict-origin-when-cross-origin\npermissions-policy: camera=(), microphone=(), geolocation=(), payment=()\n\n=== HTML head ===\n<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Quarlo - typed SQL</title>\n<meta name=\"description\" content=\"Quarlo is a TypeScript query builder that checks every SQL query against your schema at compile time, so a renamed column fails the build, not production.\">\n<link rel=\"canonical\" href=\"https://quarlo.example/\">\n<meta property=\"og:title\" content=\"Quarlo - typed SQL for TypeScript\">\n<meta property=\"og:description\" content=\"Every SQL query checked against your schema at compile time.\">\n<meta property=\"og:url\" content=\"https://quarlo.example/\">\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:site_name\" content=\"Quarlo\">\n<meta property=\"og:image\" content=\"https://quarlo.example/og.png\">\n<meta name=\"twitter:card\" content=\"summary\">\n<meta name=\"twitter:site\" content=\"@quarlolib\">\n\n[... 1562 more characters cut from this display ...]",
 "carry": "blocker (phase 4) csp: Move the inline theme script in the head into a file under /assets/ (or give it a nonce from a Pages Function), then drop 'unsafe-inline' so _headers sends script-src 'self' with the rest of the default-src 'none' policy unchanged.\nblocker (phase 5) ai: In robots.txt delete the GPTBot group that sends Disallow: /, so GPTBot falls back to the * group's Allow: /, and add a group with User-agent: Bytespider and User-agent: Amazonbot followed by Disallow: /.\nblocker (phase 5) llms: Publish /llms.txt with an H1 of Quarlo, a one-paragraph > summary and a ## Docs section linking the getting-started guide, the API reference and the changelog, then paste it here to re-check.\nblocker (phase 6) twitter: Change twitter:card from summary to summary_large_image and add twitter:title, twitter:description and twitter:image pointing at https://quarlo.example/og.png, keeping twitter:site @quarlolib.\nfix (phase 4) hsts: Change the Strict-Transport-Security line in _headers to max-age=31536000; includeSubDomains; preload, then submit quarlo.example at hstspreload.org.\nfix (phase 5) jsonld: Keep the SoftwareApplication block and add an Organization (name Quarlo, url https://quarlo.example/) and a WebSite (name Quarlo, url https://quarlo.example/) as JSON-LD in the head.\nfix (phase 5) title: The title tag is 18 characters; lengthen it to 50-60, for example <title>Quarlo - type-safe SQL query builder for TypeScript</title> (51 characters).\nfix (phase 6) ogimage: Declare og:image:width 1200 and og:image:height 630 next to og:image (after confirming og.png is 1200 by 630), and add an og:image:alt that describes what the image shows.\nfix (phase 7) manifest: Add \"short_name\": \"Quarlo\" to /site.webmanifest; name, display, theme_color, background_color and the 192 and 512 icons are already there.",
 "facts": "The page's checks (4 fail, 5 warn, 16 pass, 0 not checkable from what was pasted):\n- phase 4 | csp | fail | 'unsafe-inline' in script-src - use nonces\n- phase 4 | hsts | warn | \"max-age=31536000; includeSubDomains\" - no preload\n- phase 4 | xfo | pass | \"DENY\"\n- phase 4 | nosniff | pass | \"nosniff\"\n- phase 4 | referrer | pass | \"strict-origin-when-cross-origin\"\n- phase 4 | permissions | pass | denies all four\n- phase 4 | fingerprint | pass | none\n- phase 4 | secrets | pass | none in what was pasted (check the JS bundle too)\n- phase 5 | robots | pass | Sitemap: https://quarlo.example/sitemap.xml\n- phase 5 | indexable | pass | Googlebot allowed (via the * group)\n- phase 5 | ai | fail | GPTBot is blocked via its own group, policy says allow; Bytespider is allowed via the * group, policy says block; Amazonbot is allowed via the * group, policy says block\n- phase 5 | sitemap | pass | 4 URLs, 4 lastmod\n- phase 5 | llms | fail | not pasted - paste /llms.txt\n- phase 5 | jsonld | warn | types: SoftwareApplication - add Organization and WebSite\n- phase 5 | title | warn | 18 characters: \"Quarlo - typed SQL\"\n- phase 5 | description | pass | 154 characters\n- phase 5 | canonical | pass | https://quarlo.example/\n- phase 5 | noindex | pass | indexable\n- phase 6 | og | pass | all five\n- phase 6 | ogimage | warn | https://quarlo.example/og.png - no width/height - no og:image:alt\n- phase 6 |\n\n[... 1126 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "files",
 "summary": "The words Quarlo's launch still needs: an llms.txt for the docs, titles and descriptions for the three pages, the social card text and Organization, WebSite and SoftwareApplication JSON-LD.",
 "llms_txt": "# Quarlo\n\n> Quarlo is an open-source TypeScript library for typed SQL query building: it checks every SQL query against your schema at compile time, so a renamed column fails the build, not production.\n\n## Docs\n\n- [Getting [... 276 more characters cut from this display]",
 "meta": [
  {
   "path": "/",
   "title": "Quarlo - type-safe SQL query builder for TypeScript",
   "description": "Quarlo is a TypeScript query builder that checks every SQL query against your schema at compile time, so a renamed column fails the build, not production."
  },
  {
   "path": "/guide/getting-started",
   "title": "Getting started with Quarlo - your first typed SQL query",
   "description": "Install Quarlo and write your first typed query with the TypeScript SQL builder that checks every query against your schema at compile time, not in production."
  },
  {
   "path": "/api",
   "title": "Quarlo API reference - the typed SQL builder for TypeScript",
   "description": "The Quarlo API reference: the open-source TypeScript library for typed SQL query building, where every query is checked against your schema at compile time."
  }
 ],
 "og": {
  "title": "Quarlo - typed SQL for TypeScript",
  "description": "Every SQL query checked against your schema at compile time, so a renamed column fails the build, not production.",
  "image_alt": "The Quarlo social card: typed SQL for TypeScript"
 },
 "jsonld": [
  {
   "@context": "https://schema.org",
   "@type": "Organization",
   "name": "Quarlo",
   "url": "https://quarlo.example/",
   "sameAs": [
    "https://x.com/quarlolib"
   ]
  },
  {
   "@context": "https://schema.org",
   "@type": "WebSite",
   "name": "Quarlo",
   "url": "https://quarlo.example/",
   "inLanguage": "en"
  },
  {
   "@context": "https://schema.org",
   "@type": "SoftwareApplication",
   "name": "Quarlo",
   "applicationCategory": "DeveloperApplication",
   "url": "https://quarlo.example/",
   "description": "An open-source TypeScript library for typed SQL query building that checks every query against your schema at compile time."
  }
 ],
 "caveats": [
  "Match the image alt text to what og.png actually shows."
 ]
}

Cut from this display only (the saved reply and the check use the full text): llms_txt (498 characters, 222 shown).

What step 5 prints for it: / - Quarlo - type-safe SQL query builder for TypeScript (51) | /guide/getting-started - Getting started with Quarlo - your first typed SQL query (56) | /api - Quarlo API reference - the typed SQL builder for TypeScript (59)

The page's check: kept - nothing to flag

report - A product's marketing site

The page read it as a marketing / lead-gen: launch blockers (4 fail, 7 warn, 14 pass). Fail rows: csp, permissions, ai, noindex. Warn rows: xfo, referrer, fingerprint, sitemap, jsonld, manifest, deprecated. AI crawlers: the skill's decision matrix allows AI crawlers on a marketing site - citations on a sales page are good.

Input (served is 3262 characters; the first 1395 are shown; facts is 2974 characters; the first 1399 are shown):

{
 "task": "report",
 "site": "Site: https://www.brindlewyn.example\nSite type: Marketing / lead-gen\nHost: Vercel (vercel.json)\nCSP level: balanced-allow-self\nLanguages: single locale (en)\nBrand: Brindlewyn\nWhat the site is: Brindlewyn is shift-scheduling software for clinics; the marketing site has the product tour, pricing and a demo request form.\nTwitter / X handle: @brindlewynhq\nOther pages:\n- /pricing | Pricing - Brindlewyn\n- /demo | Book a demo",
 "served": "=== Response headers ===\nHTTP/1.1 308 Permanent Redirect\nlocation: https://www.brindlewyn.example/\n\nHTTP/2 200\ncontent-type: text/html; charset=utf-8\nserver: Vercel\nx-powered-by: Next.js\ncontent-security-policy-report-only: default-src 'self'; script-src 'self' https://www.googletagmanager.com; frame-ancestors 'none'\nstrict-transport-security: max-age=63072000; includeSubDomains; preload\nx-content-type-options: nosniff\nx-frame-options: SAMEORIGIN\nreferrer-policy: origin-when-cross-origin\n\n=== HTML head ===\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>Brindlewyn - shift scheduling that clinics actually finish</title>\n<meta name=\"description\" content=\"Brindlewyn builds the clinic rota in minutes: availability, skills and labour rules in one place, with swaps that staff can approve from their phones.\">\n<meta name=\"robots\" content=\"noindex, nofollow\">\n<link rel=\"canonical\" href=\"https://www.brindlewyn.example/\">\n<meta property=\"og:title\" content=\"Brindlewyn - shift scheduling for clinics\">\n<meta property=\"og:description\" content=\"Build the clinic rota in minutes, with swaps staff approve from their phones.\">\n<meta property=\"og:url\" content=\"https://www.brindlewyn.example/\">\n<meta property=\"og:type\" content=\"website\">\n<meta property=\"og:site_name\" content=\"Brindlewyn\">\n<meta property=\"og:image\" content=\"https://www.brindlewyn.example/og/home.png\">\n<meta\n\n[... 1867 more characters cut from this display ...]",
 "carry": "",
 "facts": "The page's checks (4 fail, 7 warn, 14 pass, 0 not checkable from what was pasted):\n- phase 4 | csp | fail | report-only - nothing is enforced yet\n- phase 4 | hsts | pass | \"max-age=63072000; includeSubDomains; preload\"\n- phase 4 | xfo | warn | \"SAMEORIGIN\"\n- phase 4 | nosniff | pass | \"nosniff\"\n- phase 4 | referrer | warn | \"origin-when-cross-origin\"\n- phase 4 | permissions | fail | missing\n- phase 4 | fingerprint | warn | X-Powered-By: Next.js\n- phase 4 | secrets | pass | none in what was pasted (check the JS bundle too)\n- phase 5 | robots | pass | Sitemap: https://www.brindlewyn.example/sitemap.xml\n- phase 5 | indexable | pass | Googlebot allowed (via the * group)\n- phase 5 | ai | fail | Bytespider is allowed via the * group, policy says block; Amazonbot is allowed via the * group, policy says block\n- phase 5 | sitemap | warn | 4 URLs, no lastmod; 1 on another origin, e.g. https://staging.brindlewyn.example/tour\n- phase 5 | llms | pass | H1 title, a summary blockquote, 1 ## section, 2 links\n- phase 5 | jsonld | warn | types: Organization - add WebSite\n- phase 5 | title | pass | 58 characters: \"Brindlewyn - shift scheduling that clinics actually finish\"\n- phase 5 | description | pass | 150 characters\n- phase 5 | canonical | pass | https://www.brindlewyn.example/\n- phase 5 | noindex | fail | meta robots \"noindex, nofollow\"\n- phase 6 | og | pass | all five\n- phase 6 | ogimage |\n\n[... 1575 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "report",
 "summary": "Not ready to launch: the home page sends meta robots noindex, nofollow, so search engines will drop it - remove that tag before anything else.",
 "phases": [
  {
   "phase": 4,
   "status": "blocked",
   "blockers": [
    {
     "id": "csp",
     "fix": "The policy is sent as Content-Security-Policy-Report-Only, so nothing is enforced; once the reports are clean, send it from vercel.json as Content-Security-Policy, adding style-src 'self' fonts.googleapis.com for the Inter stylesheet the [... 12 more characters cut from this display]"
    },
    {
     "id": "permissions",
     "fix": "Add a Permissions-Policy header in vercel.json: camera=(), microphone=(), geolocation=(), payment=()."
    }
   ],
   "fixes": [
    {
     "id": "xfo",
     "fix": "Change X-Frame-Options from SAMEORIGIN to DENY in vercel.json, matching the frame-ancestors 'none' already in the CSP."
    },
    {
     "id": "referrer",
     "fix": "Change Referrer-Policy from origin-when-cross-origin to strict-origin-when-cross-origin in vercel.json."
    },
    {
     "id": "fingerprint",
     "fix": "Stop sending X-Powered-By: Next.js by setting poweredByHeader: false in next.config.js."
    }
   ],
   "manual": [
    "securityheaders.com grade A+",
    "observatory.mozilla.org score 90 or more",
    "No secrets in the JS bundle (search the Network tab for sk_, pk_, AKIA, ghp_, Bearer)",
    "HSTS submitted to hstspreload.org"
   ]
  },
  {
   "phase": 5,
   "status": "blocked",
   "blockers": [
    {
     "id": "ai",
     "fix": "Add a robots.txt group with User-agent: Bytespider and User-agent: Amazonbot followed by Disallow: /; the AI crawlers you want cited stay allowed through the * group."
    },
    {
     "id": "noindex",
     "fix": "Remove <meta name=\"robots\" content=\"noindex, nofollow\"> from the production head - it hides every page that carries it - and confirm no X-Robots-Tag header replaces it."
    }
   ],
   "fixes": [
    {
     "id": "sitemap",
     "fix": "Remove https://staging.brindlewyn.example/tour from sitemap.xml (list the production tour URL only if it exists) and give every url a lastmod date."
    },
    {
     "id": "jsonld",
     "fix": "Keep the Organization block and add a WebSite JSON-LD block with name Brindlewyn and url https://www.brindlewyn.example/."
    }
   ],
   "manual": [
    "Google Rich Results Test on the JSON-LD",
    "Keyword shortlist per page (Google Trends for direction, Ahrefs for size)",
    "AI visibility check of how the site appears in AI answers",
    "Typo and grammar pass",
    "Every important page within three clicks of the home page"
   ]
  },
  {
   "phase": 6,
   "status": "clean",
   "blockers": [],
   "fixes": [],
   "manual": [
    "opengraph.xyz preview (Facebook, LinkedIn, Slack, Discord, WhatsApp)",
    "Paste the URL in a Slack channel and a LinkedIn DM"
   ]
  },
  {
   "phase": 7,
   "status": "fixes",
   "blockers": [],
   "fixes": [
    {
     "id": "manifest",
     "fix": "Paste /site.webmanifest so it can be checked for name, short_name, display, theme_color, background_color and the 192 and 512 icons."
    },
    {
     "id": "deprecated",
     "fix": "Delete <link rel=\"mask-icon\" href=\"/safari-pinned-tab.svg\">, <meta name=\"msapplication-TileColor\"> and <meta name=\"msapplication-TileImage\"> from the head."
    }
   ],
   "manual": [
    "/favicon.ico served as multi-resolution 16/32/48",
    "apple-touch-icon is 180x180 with an opaque background"
   ]
  }
 ],
 "order": [
  "noindex",
  "csp",
  "permissions",
  "ai",
  "sitemap",
  "referrer",
  "xfo",
  "jsonld",
  "fingerprint",
  "manifest",
  "deprecated"
 ],
 "caveats": [
  "Check the browser console for CSP report-only violations from Google Tag Manager and Google Fonts before enforcing."
 ]
}

Cut from this display only (the saved reply and the check use the full text): phases[0].blockers[0].fix (249 characters, 237 shown).

What step 5 prints for it: 1. noindex | 2. csp | 3. permissions | 4. ai | 5. sitemap | 6. referrer | 7. xfo | 8. jsonld | 9. fingerprint | 10. manifest | 11. deprecated

The page's check: kept - nothing to flag

Build the input yourself

Load the engine in node (global.window = global; require("./launchkit.js"); require("./launchreply.js")), then:

const K = LaunchKit, R = LaunchReply;
const f = K.form(Object.assign(K.blank(), { url: "https://a.example", siteType: "doc", host: "netlify", cspLevel: "strict",
  headers: "HTTP/2 200\nstrict-transport-security: max-age=31536000; includeSubDomains\nx-content-type-options: nosniff",
  head: "<title>A - docs</title><link rel=\"canonical\" href=\"https://a.example/\">",
  robots: "User-agent: *\nAllow: /\nSitemap: https://a.example/sitemap.xml" }));
// f.rows (every check: phase, id, label, status, evidence), f.verdict, f.policy, f.findings
// K.robotsTxt(f), K.headersConfig(f) (here _headers), K.manifestJson(f) - the files that need no judgement
const b = K.buildInput(f, "report");                         // or "files" (R.laneForm(form, "files", report) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "report");
// R.check(reply, b)                                         // {status: kept or flagged, notes}

Get a token for the steps below from /tokens.html.

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="YOUR_TOKEN"   # from https://launch-proof.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":"launch-proof"}' | 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": "report", "site": "Site: https://quarlo.example\nSite type: Doc site or library homepage\n...", "served": "=== Response headers ===\nHTTP/2 200\n...", "carry": "", "facts": "The page's checks (4 fail, 5 warn, ...):\n- phase 4 | csp | fail | ..."}
# - 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 launch-proof:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A report reply carries summary, phases, order and caveats; a files reply carries summary, llms_txt, meta, og, jsonld and caveats. The code below prints the gist of either: the order to fix things in, or each page's title.

KEY="launch-proof:$(jq -r .task input.json):$(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}'
jq -r 'if .task == "files" then (.meta[] | "\(.path) - \(.title)") 
       else (.order | to_entries[] | "\(.key + 1). \(.value)") end' reply.json

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: launch-proof:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json