← llms.txt Desk / API
Tokens

llms.txt Desk API

Two metered lanes, one JSON object back. create writes a new llms.txt from a page list; update brings an existing one up to date for what changed and answers every problem the checker found.

The checker itself (format rules, the reference-parser port, page-list coverage, the starter draft) runs only in the browser page, makes no API call and costs nothing, so it is not part of this API. Over the API you send the checker's results as the facts string and get the model's reply as it is; the web page additionally re-checks every returned link and the returned file, which you would do yourself.

Everything below is derived from the app's own files: sdk.js (endpoints, headers, envelope), deskkit.js (buildInput(), the exact run input), SKILL.md (the model's instructions and reply shapes) and recon.js (how the page reads a reply).

The task field comes first

Every run body is a JSON object whose task field picks the lane. There are exactly two (DeskKit.LANES = ["create", "update"]). SKILL.md tells the model to honour task, and if it is missing or unrecognised to pick the closer lane and say which one in the reply's own task field — so always send it, and read the reply's task back.

taskWhat it doesSendReply keys
"create"Writes a new llms.txt from the pages in your page list (sitemap, URL list, Markdown links, tree output or repository paths).task, facts; optional about, nametask, headline, llms_txt, sections, left_out, assumptions, to_check
"update"Brings an existing llms.txt up to date for what changed and fixes what the checker found, answering every finding.task, llms, facts; optional changestask, headline, verdict, finding_responses, changes, updated_llms_txt, to_check

Input fields

Exactly what DeskKit.buildInput(lane, ...) produces. Optional fields are sent only when not empty. The limits are the page's own clipping in deskkit.js; sdk.js enforces none of them, so they are what to stay inside rather than a documented server limit.

FieldTypeLaneMeaningPage limit
taskstringboth"create" or "update".—
factsstring (JSON)bothA JSON object serialised to a string (JSON.stringify(...)), written by the browser checker. Its keys differ per lane; see the next section. Send a string, not a nested object.create: first 250 pages; update: first 60 findings, first 300 links, first 250 pages.
aboutstringcreate, optionalWhat the project or site is, in your words (often a README excerpt). Without it the summary comes from page titles alone and is marked as a guess.6,000 characters, cut at the last sentence or line end and marked [... cut by llms.txt Desk].
namestringcreate, optionalThe project or site name, used as the H1.120 characters, trimmed.
llmsstringupdateThe current llms.txt, whole, with line endings normalised to \n.The page refuses files over 24,000 characters (an update returns the whole file).
changesstringupdate, optionalWhat changed on the site or in the repository, in your words. Pages it names are added only with URLs it or facts.pages gives.4,000 characters, cut like about.
retry_notestringboth, optionalSend only when you retry because the previous reply did not parse as the JSON object; say what was wrong. Use a new Idempotency-Key for the retry.—
retry_notestringboth, optionalNot typed by the user: the page adds it on its single automatic retry when a reply was not the one valid JSON object the instructions require. That retry is a new run with a new Idempotency-Key (attempt a2).—

A create needs at least one page without a skip hint; an update needs a non-empty llms. The page will not build an input otherwise (DeskKit.laneReady), but /estimate does not check this for you.

The facts string

facts is what the browser checker knows, JSON-encoded. Build the object, then serialise it: json.dumps(facts), JSON.stringify(facts) and so on. SKILL.md tells the model to treat source "spec" and "parser" findings of severity "error" as true, "skill" and "advice" findings as judgement calls, and "coverage" findings as questions only you can settle.

task "create" (deskkit.js createFacts)

KeyValue
pagesArray of pages, in your order, at most 250 (pages without a skip hint first, then skipped ones while there is room). Each page: id (P1, P2, ...), url, title, kind (e.g. docs, examples, reference, config, project, other); and only when they apply: md_url (the page's Markdown version, when the list also gave it), title_from_path: true (the title was made from the URL), skip (a reason such as "listing or pagination page" or "account or search page"), target (only when not "html", e.g. "markdown").
pages_totalHow many pages the list had in total.
page_list_formatHow the list was read, e.g. "sitemap".
base_urlWhere the file will live (e.g. https://sluicewren.example/llms.txt), or "".
pages_not_sentOnly when the list was cut: an object of reason to count, e.g. {"over the limit": 40}.

task "update" (deskkit.js updateFacts)

KeyValue
checkerA string naming the checker and its rule sets.
statusThe checker's verdict on the current file: broken (an error), fix_first (warnings), ok.
file_urlThe file's address, or "".
findingsAt most 60 of {id, severity, source, code, where, line, text, quote, refs}: ids F1, F2, ...; severity error, warning or info; source spec, parser, skill, advice or coverage; code the rule (e.g. stray-text); line a number or null; refs up to 40 link or page ids.
findings_totalHow many findings the checker had (may exceed the 60 sent).
linksAt most 300 of {id, section, title, url}, ids L1, L2, ...
links_totalHow many links the file has.
reference_parser"reads the file" or "throws: ..." with the reason.
pages, pages_total, pages_not_sentOnly when you also gave a current page list: as for create.
coverageOnly with a page list: {links_not_in_pages, external_links, pages_not_linked}, arrays of link or page ids.

The reply

A run's result is a job. When it has status: "succeeded", the model's reply is a string at output.output (the field sdk.js and app.js read). SKILL.md requires that string to be one JSON object and nothing else, so decode twice: the envelope, then output.output. recon.js is tolerant — it strips a stray code fence and takes the first balanced {...} — and the snippets below do the same with a first-{ to last-} slice. A job with truncated: true was cut short (the page closes the open JSON and shows what arrived).

{
  "data": {
    "status": "succeeded",
    "charged_credits": 1234,
    "truncated": false,
    "output": {
      "output": "{\"task\": \"create\", \"headline\": \"An llms.txt for ...\", \"llms_txt\": \"# Sluicewren\\n\\n> ...\", ...}"
    }
  }
}

Illustrative values; the string is shortened. Only the fields sdk.js or the page read are shown.

task "create" reply

KeyTypeMeaning (SKILL.md)How recon.js normalize() reads it
taskstring"create".Lower-cased; if it names the other lane, that lane's shape is used and flagged as a mismatch.
headlinestringOne sentence: what the file covers and how it is organised.Stringified.
llms_txtstringThe complete new file; line breaks are \n; ends with one line break. Every URL is copied from facts.pages[].url / md_url or from about.A code fence around it is removed, CRLF becomes LF, trailing space is trimmed to one final \n.
sections{name, why, refs}[]One per H2 in order; refs are exactly the page ids linked there.First 40; refs upper-cased and split on spaces, commas, semicolons or slashes.
left_out{refs, why}[]Pages not linked, with a short reason. Every page id appears exactly once across sections and left_out.First 80.
assumptionsstring[]Each guess the input did not state.First 20, empty strings dropped.
to_checkstring[]What to verify before publishing.First 20.

task "update" reply

KeyTypeMeaning (SKILL.md)How recon.js normalize() reads it
taskstring"update".As for create.
headlinestringOne sentence: what was out of date or wrong and what the updated file does.Stringified.
verdictstringOn the CURRENT file: broken (a real error), out_of_date (content must change), ok (nothing to change).Lower-cased, spaces and hyphens to _; anything else becomes broken, raw value kept.
finding_responses{ref, stance, note}[]One per facts.findings id, in id order; stance fixed, not_a_problem or needs_you.First 200; ref (or id) upper-cased; an unknown stance becomes unclear.
changes{action, where, what, why}[]One per edit; action add, remove, edit, move or format; every link added, removed, re-pointed or moved is named with its URL in what.First 120; an unknown action becomes edit; what falls back to change.
updated_llms_txtstring or nullThe complete updated file (not a diff), or null when nothing needs to change.Read like llms_txt; null means no file.
to_checkstring[]Things only you can confirm.First 20.

When facts.findings is empty and changes asks for nothing, the expected reply is verdict "ok", empty finding_responses and changes, and updated_llms_txt null.

Worked examples

Both bodies are the app's own examples, built by DeskKit.buildInput() exactly as the page sends them, with facts shortened for display (noted under each). Each facts value is still valid JSON inside a JSON string. Save a body as create.json or update.json to use it with the curl steps below.

create: a docs site's sitemap (Sluicewren)

A sitemap with docs, guides, reference pages, a blog, tag and pagination listings and a login page. The checker marked the listings and the login page with skip hints and matched Markdown versions (md_url).

{
  "task": "create",
  "facts": "{\"pages\":[{\"id\":\"P1\",\"url\":\"https://sluicewren.example/\",\"title\":\"Home\",\"kind\":\"other\",\"title_from_path\":true},{\"id\":\"P2\",\"url\":\"https://sluicewren.example/docs/\",\"title\":\"Docs\",\"kind\":\"docs\",\"title_from_path\":true},{\"id\":\"P3\",\"url\":\"https://sluicewren.example/docs/quickstart\",\"title\":\"Quickstart\",\"kind\":\"examples\",\"md_url\":\"https://sluicewren.example/docs/quickstart.md\",\"title_from_path\":true},{\"id\":\"P4\",\"url\":\"https://sluicewren.example/docs/concepts/streams\",\"title\":\"Streams\",\"kind\":\"docs\",\"md_url\":\"https://sluicewren.example/docs/concepts/streams.md\",\"title_from_path\":true},{\"id\":\"P5\",\"url\":\"https://sluicewren.example/docs/concepts/backpressure\",\"title\":\"Backpressure\",\"kind\":\"docs\",\"md_url\":\"https://sluicewren.example/docs/concepts/backpressure.md\",\"title_from_path\":true},{\"id\":\"P14\",\"url\":\"https://sluicewren.example/blog/page/2/\",\"title\":\"2\",\"kind\":\"other\",\"title_from_path\":true,\"skip\":\"listing or pagination page\"},{\"id\":\"P17\",\"url\":\"https://sluicewren.example/login\",\"title\":\"Login\",\"kind\":\"other\",\"title_from_path\":true,\"skip\":\"account or search page\"}],\"pages_total\":7,\"page_list_format\":\"sitemap\",\"base_url\":\"https://sluicewren.example/llms.txt\"}",
  "about": "Sluicewren is an open-source stream-processing library for TypeScript and Node.js.\nYou describe a pipeline as a chain of typed operators (map, filter, window, join) and Sluicewren\nruns it with built-in backpressure, so a slow sink never makes the process run out of memory.\nSources and sinks exist for Kafka, Postgres logical replication and plain files. Version 2.0 added\nexactly-once delivery for Kafka. It is MIT licensed; the hosted dashboard on the pricing page is a\nseparate paid product and is not part of the library.",
  "name": "Sluicewren"
}

Shortened: the real input sends all 18 pages (P1–P18) with pages_total: 18; this sample keeps 7 of them (ids unchanged) and sets pages_total to 7 so it stays self-consistent. about and name are unchanged.

The same facts decoded:

{
  "pages": [
    {
      "id": "P1",
      "url": "https://sluicewren.example/",
      "title": "Home",
      "kind": "other",
      "title_from_path": true
    },
    {
      "id": "P2",
      "url": "https://sluicewren.example/docs/",
      "title": "Docs",
      "kind": "docs",
      "title_from_path": true
    },
    {
      "id": "P3",
      "url": "https://sluicewren.example/docs/quickstart",
      "title": "Quickstart",
      "kind": "examples",
      "md_url": "https://sluicewren.example/docs/quickstart.md",
      "title_from_path": true
    },
    {
      "id": "P4",
      "url": "https://sluicewren.example/docs/concepts/streams",
      "title": "Streams",
      "kind": "docs",
      "md_url": "https://sluicewren.example/docs/concepts/streams.md",
      "title_from_path": true
    },
    {
      "id": "P5",
      "url": "https://sluicewren.example/docs/concepts/backpressure",
      "title": "Backpressure",
      "kind": "docs",
      "md_url": "https://sluicewren.example/docs/concepts/backpressure.md",
      "title_from_path": true
    },
    {
      "id": "P14",
      "url": "https://sluicewren.example/blog/page/2/",
      "title": "2",
      "kind": "other",
      "title_from_path": true,
      "skip": "listing or pagination page"
    },
    {
      "id": "P17",
      "url": "https://sluicewren.example/login",
      "title": "Login",
      "kind": "other",
      "title_from_path": true,
      "skip": "account or search page"
    }
  ],
  "pages_total": 7,
  "page_list_format": "sitemap",
  "base_url": "https://sluicewren.example/llms.txt"
}

A reply from a test run of this lane on the full 18-page input (after decoding output.output). Your wording will differ; the shape will not.

{
  "task": "create",
  "headline": "A Sluicewren llms.txt organised into Docs, Getting Started, Concepts, Guides, Reference, Configuration and Optional sections, covering the quickstart, core concepts, guides and reference docs, leaving out listing, account and pricing pages.",
  "llms_txt": "# Sluicewren\n\n> Sluicewren is an open-source stream-processing library for TypeScript and Node.js for building pipelines of typed operators with built-in backpressure.\n\nPipelines are chains of typed operators (map, filter, window, join) that Sluicewren runs with built-in backpressure, so a slow sink cannot make the process run out of memory. Sources and sinks are available for Kafka, Postgres logical replication and plain files. Version 2.0 added exactly-once delivery for Kafka. Sluicewren is MIT licensed; the hosted dashboard is a separate paid product and is not part of the library.\n\n## Docs\n\n- [Home](https://sluicewren.example/): project site entry point\n- [Docs](https://sluicewren.example/docs/): documentation index\n\n## Getting Started\n\n- [Quickstart](https://sluicewren.example/docs/quickstart.md): set up and run a first pipeline\n\n## Concepts\n\n- [Streams](https://sluicewren.example/docs/concepts/streams.md): the stream and operator model\n- [Backpressure](https://sluicewren.example/docs/concepts/backpressure.md): how backpressure is handled\n\n## Guides\n\n- [Kafka source](https://sluicewren.example/docs/guides/kafka-source.md): read from a Kafka source\n- [Testing pipelines](https://sluicewren.example/docs/guides/testing-pipelines.md): test a pipeline\n\n## Reference\n\n- [API](https://sluicewren.example/docs/reference/api): API reference\n- [CLI](https://sluicewren.example/docs/reference/cli): CLI reference\n- [Config](https://sluicewren.example/docs/reference/config): configuration reference\n\n## Configuration\n\n- [Kubernetes](https://sluicewren.example/docs/deploy/kubernetes): deploy on Kubernetes\n\n## Optional\n\n- [Changelog](https://sluicewren.example/changelog): release history\n- [Blog](https://sluicewren.example/blog/): project articles and updates\n- [Sluicewren 2 0](https://sluicewren.example/blog/sluicewren-2-0): post about the 2.0 release\n",
  "sections": [
    {
      "name": "Docs",
      "why": "Orients an agent at the site root and the documentation index.",
      "refs": [
        "P1",
        "P2"
      ]
    },
    {
      "name": "Getting Started",
      "why": "The single page needed to set up and run a first pipeline.",
      "refs": [
        "P3"
      ]
    },
    {
      "name": "Concepts",
      "why": "The two core ideas (streams, backpressure) an agent needs before writing a pipeline.",
      "refs": [
        "P4",
        "P5"
      ]
    },
    {
      "name": "Guides",
      "why": "Task-focused guides for a common source and for testing.",
      "refs": [
        "P6",
        "P7"
      ]
    },
    {
      "name": "Reference",
      "why": "The API, CLI and config reference docs.",
      "refs": [
        "P8",
        "P9",
        "P10"
      ]
    },
    {
      "name": "Configuration",
      "why": "How to deploy Sluicewren on Kubernetes.",
      "refs": [
        "P11"
      ]
    },
    {
      "name": "Optional",
      "why": "Secondary background: release history and blog posts.",
      "refs": [
        "P12",
        "P13",
        "P15"
      ]
    }
  ],
  "left_out": [
    {
      "refs": [
        "P14",
        "P16"
      ],
      "why": "Pagination and tag listing pages, marked skip."
    },
    {
      "refs": [
        "P17"
      ],
      "why": "Account/login page, marked skip."
    },
    {
      "refs": [
        "P18"
      ],
      "why": "Pricing page for the paid hosted dashboard, a separate product from the open-source library."
    }
  ],
  "assumptions": [
    "Grouped pages into sections (Docs, Getting Started, Concepts, Guides, Reference, Configuration, Optional) by URL path and title, since facts.pages does not state a site structure."
  ],
  "to_check": [
    "Whether the Home and Docs landing pages are worth keeping as separate links, or duplicate what the summary already covers."
  ]
}

Shortened: assumptions and to_check had three entries each; the first of each is shown. Everything else is as returned.

update: a broken file and a note on what changed (Dockvarren)

The current file has a * bullet, a note without a colon, a stray prose line inside a section and two sections both called Docs; changes says a page moved, one was dropped and two are new. A current page list was given, so facts carries pages and coverage too.

{
  "task": "update",
  "llms": "# Dockvarren\n\n> Dockvarren is a self-hosted container registry with image signing and vulnerability scanning.\n\nDockvarren stores OCI images and Helm charts. This file covers the administrator documentation.\n\n## Docs\n\n- [Installation](https://dockvarren.example/docs/install.md): Install Dockvarren with Docker Compose or Helm\n- [Deploying behind a proxy](https://dockvarren.example/docs/deploy.md) - TLS termination and proxy headers\n* [Signing images](https://dockvarren.example/docs/signing.md): Sign and verify images with cosign\nSee also the FAQ page for common questions.\n\n## Docs\n\n- [Vulnerability scanning](https://dockvarren.example/docs/scanning.md): Configure a scanner and scan policies\n- [Migrating from v1](https://dockvarren.example/docs/migrate-v1.md): Upgrade path from Dockvarren 1.x\n\n## Optional\n\n- [Release notes](https://dockvarren.example/changelog.md)\n",
  "facts": "{\"checker\":\"llms.txt Desk (llmstxt.org v2 format rules, the reference parser llms_txt/miniparse.py ported line for line, the create-llms and update-llms skills' checklist, and coverage against the page list the user gave)\",\"status\":\"broken\",\"file_url\":\"https://dockvarren.example/llms.txt\",\"findings\":[{\"id\":\"F2\",\"severity\":\"warning\",\"source\":\"spec\",\"code\":\"notes-no-colon\",\"where\":\"section Docs\",\"line\":10,\"text\":\"Text after the link is not introduced by \\\":\\\" (\\\"- TLS termination and proxy headers\\\"). The proposal's form is [name](url): notes; the reference parser drops notes without the colon.\",\"quote\":\"- [Deploying behind a proxy](https://dockvarren.example/docs/deploy.md) - TLS termination and proxy headers\",\"refs\":[\"L2\"]},{\"id\":\"F5\",\"severity\":\"error\",\"source\":\"spec\",\"code\":\"stray-text\",\"where\":\"section Docs\",\"line\":12,\"text\":\"1 line in \\\"Docs\\\" is not a list item. A file-list section holds only a Markdown list of links; put prose in the details under the summary. The reference parser cannot read such a line.\",\"quote\":\"See also the FAQ page for common questions.\",\"refs\":[]},{\"id\":\"F6\",\"severity\":\"warning\",\"source\":\"parser\",\"code\":\"duplicate-section\",\"where\":\"section Docs\",\"line\":14,\"text\":\"Two sections are called \\\"Docs\\\" (lines 7 and 14). The reference parser returns sections as a dictionary, so it keeps only the last one and the first one's links disappear. Merge them or rename one.\",\"quote\":\"\",\"refs\":[]}],\"findings_total\":3,\"links\":[{\"id\":\"L1\",\"section\":\"Docs\",\"title\":\"Installation\",\"url\":\"https://dockvarren.example/docs/install.md\"},{\"id\":\"L2\",\"section\":\"Docs\",\"title\":\"Deploying behind a proxy\",\"url\":\"https://dockvarren.example/docs/deploy.md\"},{\"id\":\"L3\",\"section\":\"Docs\",\"title\":\"Signing images\",\"url\":\"https://dockvarren.example/docs/signing.md\"},{\"id\":\"L4\",\"section\":\"Docs\",\"title\":\"Vulnerability scanning\",\"url\":\"https://dockvarren.example/docs/scanning.md\"},{\"id\":\"L5\",\"section\":\"Docs\",\"title\":\"Migrating from v1\",\"url\":\"https://dockvarren.example/docs/migrate-v1.md\"},{\"id\":\"L6\",\"section\":\"Optional\",\"title\":\"Release notes\",\"url\":\"https://dockvarren.example/changelog.md\"}],\"links_total\":6,\"reference_parser\":\"reads the file\",\"pages\":[{\"id\":\"P1\",\"url\":\"https://dockvarren.example/docs/install.md\",\"title\":\"Install\",\"kind\":\"config\",\"title_from_path\":true,\"target\":\"markdown\"},{\"id\":\"P2\",\"url\":\"https://dockvarren.example/docs/deployment.md\",\"title\":\"Deployment\",\"kind\":\"config\",\"title_from_path\":true,\"target\":\"markdown\"},{\"id\":\"P3\",\"url\":\"https://dockvarren.example/docs/signing.md\",\"title\":\"Signing\",\"kind\":\"docs\",\"title_from_path\":true,\"target\":\"markdown\"},{\"id\":\"P4\",\"url\":\"https://dockvarren.example/docs/scanning.md\",\"title\":\"Scanning\",\"kind\":\"docs\",\"title_from_path\":true,\"target\":\"markdown\"},{\"id\":\"P5\",\"url\":\"https://dockvarren.example/docs/replication.md\",\"title\":\"Replication\",\"kind\":\"docs\",\"title_from_path\":true,\"target\":\"markdown\"},{\"id\":\"P6\",\"url\":\"https://dockvarren.example/docs/cli.md\",\"title\":\"CLI\",\"kind\":\"reference\",\"title_from_path\":true,\"target\":\"markdown\"},{\"id\":\"P7\",\"url\":\"https://dockvarren.example/changelog.md\",\"title\":\"Changelog\",\"kind\":\"project\",\"title_from_path\":true,\"target\":\"markdown\"},{\"id\":\"P8\",\"url\":\"https://dockvarren.example/faq.md\",\"title\":\"FAQ\",\"kind\":\"docs\",\"title_from_path\":true,\"target\":\"markdown\"}],\"pages_total\":8,\"coverage\":{\"links_not_in_pages\":[\"L2\",\"L5\"],\"external_links\":[],\"pages_not_linked\":[\"P2\",\"P5\",\"P6\",\"P8\"]}}",
  "changes": "Since the last update: the proxy page moved from /docs/deploy.md to /docs/deployment.md (same content).\nWe dropped the v1 migration guide - Dockvarren 1.x is end of life.\nNew page: the dockvarren CLI reference at https://dockvarren.example/docs/cli.md - agents keep asking how to push from CI.\nReplication between registries also got its own page."
}

Shortened: the real input sends eight findings (F1–F8, findings_total: 8); this sample keeps F2, F5 and F6 and sets findings_total to 3. llms, changes, the links, the pages and coverage are unchanged.

The same facts decoded:

{
  "checker": "llms.txt Desk (llmstxt.org v2 format rules, the reference parser llms_txt/miniparse.py ported line for line, the create-llms and update-llms skills' checklist, and coverage against the page list the user gave)",
  "status": "broken",
  "file_url": "https://dockvarren.example/llms.txt",
  "findings": [
    {
      "id": "F2",
      "severity": "warning",
      "source": "spec",
      "code": "notes-no-colon",
      "where": "section Docs",
      "line": 10,
      "text": "Text after the link is not introduced by \":\" (\"- TLS termination and proxy headers\"). The proposal's form is [name](url): notes; the reference parser drops notes without the colon.",
      "quote": "- [Deploying behind a proxy](https://dockvarren.example/docs/deploy.md) - TLS termination and proxy headers",
      "refs": [
        "L2"
      ]
    },
    {
      "id": "F5",
      "severity": "error",
      "source": "spec",
      "code": "stray-text",
      "where": "section Docs",
      "line": 12,
      "text": "1 line in \"Docs\" is not a list item. A file-list section holds only a Markdown list of links; put prose in the details under the summary. The reference parser cannot read such a line.",
      "quote": "See also the FAQ page for common questions.",
      "refs": []
    },
    {
      "id": "F6",
      "severity": "warning",
      "source": "parser",
      "code": "duplicate-section",
      "where": "section Docs",
      "line": 14,
      "text": "Two sections are called \"Docs\" (lines 7 and 14). The reference parser returns sections as a dictionary, so it keeps only the last one and the first one's links disappear. Merge them or rename one.",
      "quote": "",
      "refs": []
    }
  ],
  "findings_total": 3,
  "links": [
    {
      "id": "L1",
      "section": "Docs",
      "title": "Installation",
      "url": "https://dockvarren.example/docs/install.md"
    },
    {
      "id": "L2",
      "section": "Docs",
      "title": "Deploying behind a proxy",
      "url": "https://dockvarren.example/docs/deploy.md"
    },
    {
      "id": "L3",
      "section": "Docs",
      "title": "Signing images",
      "url": "https://dockvarren.example/docs/signing.md"
    },
    {
      "id": "L4",
      "section": "Docs",
      "title": "Vulnerability scanning",
      "url": "https://dockvarren.example/docs/scanning.md"
    },
    {
      "id": "L5",
      "section": "Docs",
      "title": "Migrating from v1",
      "url": "https://dockvarren.example/docs/migrate-v1.md"
    },
    {
      "id": "L6",
      "section": "Optional",
      "title": "Release notes",
      "url": "https://dockvarren.example/changelog.md"
    }
  ],
  "links_total": 6,
  "reference_parser": "reads the file",
  "pages": [
    {
      "id": "P1",
      "url": "https://dockvarren.example/docs/install.md",
      "title": "Install",
      "kind": "config",
      "title_from_path": true,
      "target": "markdown"
    },
    {
      "id": "P2",
      "url": "https://dockvarren.example/docs/deployment.md",
      "title": "Deployment",
      "kind": "config",
      "title_from_path": true,
      "target": "markdown"
    },
    {
      "id": "P3",
      "url": "https://dockvarren.example/docs/signing.md",
      "title": "Signing",
      "kind": "docs",
      "title_from_path": true,
      "target": "markdown"
    },
    {
      "id": "P4",
      "url": "https://dockvarren.example/docs/scanning.md",
      "title": "Scanning",
      "kind": "docs",
      "title_from_path": true,
      "target": "markdown"
    },
    {
      "id": "P5",
      "url": "https://dockvarren.example/docs/replication.md",
      "title": "Replication",
      "kind": "docs",
      "title_from_path": true,
      "target": "markdown"
    },
    {
      "id": "P6",
      "url": "https://dockvarren.example/docs/cli.md",
      "title": "CLI",
      "kind": "reference",
      "title_from_path": true,
      "target": "markdown"
    },
    {
      "id": "P7",
      "url": "https://dockvarren.example/changelog.md",
      "title": "Changelog",
      "kind": "project",
      "title_from_path": true,
      "target": "markdown"
    },
    {
      "id": "P8",
      "url": "https://dockvarren.example/faq.md",
      "title": "FAQ",
      "kind": "docs",
      "title_from_path": true,
      "target": "markdown"
    }
  ],
  "pages_total": 8,
  "coverage": {
    "links_not_in_pages": [
      "L2",
      "L5"
    ],
    "external_links": [],
    "pages_not_linked": [
      "P2",
      "P5",
      "P6",
      "P8"
    ]
  }
}

A reply from a test run of this lane on the full eight-finding input:

{
  "task": "update",
  "headline": "Fixed a stray-text error and a duplicate 'Docs' section, repaired a missing colon and a '*' bullet, re-pointed the moved proxy page, dropped the retired v1 guide, and added the new CLI and Replication pages.",
  "verdict": "broken",
  "finding_responses": [
    {
      "ref": "F2",
      "stance": "fixed",
      "note": "Added ': ' before the notes on 'Deploying behind a proxy' so the reference parser reads them."
    },
    {
      "ref": "F5",
      "stance": "fixed",
      "note": "Removed the stray prose line; a file-list section may only hold link items."
    },
    {
      "ref": "F6",
      "stance": "fixed",
      "note": "Merged the two 'Docs' sections into one so no links are silently dropped by the reference parser's dictionary of sections."
    }
  ],
  "changes": [
    {
      "action": "move",
      "where": "Docs",
      "what": "Re-pointed 'Deploying behind a proxy' from https://dockvarren.example/docs/deploy.md to https://dockvarren.example/docs/deployment.md, and added the missing ': ' before its notes",
      "why": "changes said the proxy docs moved to /docs/deployment.md; F2 flagged the notes weren't introduced by a colon"
    },
    {
      "action": "format",
      "where": "Docs",
      "what": "Changed the bullet on 'Signing images' (https://dockvarren.example/docs/signing.md) from '*' to '-'",
      "why": "F4: the reference parser only reads '- ' items"
    },
    {
      "action": "remove",
      "where": "Docs",
      "what": "Removed the line 'See also the FAQ page for common questions.'",
      "why": "F5: a file-list section may contain only link items, not prose"
    }
  ],
  "updated_llms_txt": "# Dockvarren\n\n> Dockvarren is a self-hosted container registry with image signing and vulnerability scanning.\n\nDockvarren stores OCI images and Helm charts. This file covers the administrator documentation.\n\n## Docs\n\n- [Installation](https://dockvarren.example/docs/install.md): Install Dockvarren with Docker Compose or Helm\n- [Deploying behind a proxy](https://dockvarren.example/docs/deployment.md): TLS termination and proxy headers\n- [Signing images](https://dockvarren.example/docs/signing.md): Sign and verify images with cosign\n- [Vulnerability scanning](https://dockvarren.example/docs/scanning.md): Configure a scanner and scan policies\n- [CLI](https://dockvarren.example/docs/cli.md): Push images from CI with the Dockvarren CLI\n- [Replication](https://dockvarren.example/docs/replication.md): Replication between registries\n\n## Optional\n\n- [Release notes](https://dockvarren.example/changelog.md): Notes for each Dockvarren release\n",
  "to_check": [
    "FAQ (https://dockvarren.example/faq.md) is in the page list but not linked - add it under Docs if agents need it; changes didn't mention it."
  ]
}

Shortened: the full reply has one finding_responses entry per finding (eight, all fixed), eight changes and two to_check items; the entries for F2, F5 and F6, the first three changes and the first check are shown. updated_llms_txt is complete.

Base URL and envelope

Base URL: https://api.skillsafe.ai/v1/app-api. sdk.js builds every call as API_BASE + "/v1/app-api/..." with API_BASE = "https://api.skillsafe.ai", and sends:

There is no app or slug header: the token is scoped to the app (the slug goes in only once, in the /guest body or the sign-in URL). Every JSON response is an envelope: on success {"data": ...} (paginated calls add meta.pagination); on a non-2xx status {"error": {"code", "message", "details"}}, which sdk.js turns into an Error carrying status (the HTTP status), code and details, falling back to the HTTP status text when the body is not JSON.

CallBodydataStarts a metered run?
POST /guest{"slug": "llms-desk"}{token, guest_id}no
GET /me—{subject_type, subject_id, credits, profile?}no
POST /estimatethe run bodythe page reads hold_credits, min_credits, sponsor_enabledno (sdk.js: "no charge, no job")
POST /runthe run body{job_id, ...}yes
GET /jobs/{job_id}—the job: status, output.output, charged_credits, truncated, errorno (reads a job)
POST /run-streamthe run bodyServer-Sent Events (see step 6), or a plain envelopeyes

Errors

sdk.js documents no error-code catalogue for these endpoints; it passes the server's error.code through unchanged. The table lists what sdk.js (and, where marked, app.js) actually does. Rows marked HTTP semantics describe what the standard status means and how the SDK or the page reacts — they are not codes this app documents.

Status / signalWhere it comes fromWhat to do
any non-2xx + {"error": {code, message, details}}sdk.js apiError(): throws with status, code, details; message falls back to the status text.Branch on status; show message; log code and details.
401 — HTTP semanticsUnauthorized: no token, or a stale or revoked one. sdk.js has no special handling; app.js treats a 401 from a run as "your session expired - sign in again".Get a fresh token (step 2) and retry.
402 — HTTP semanticsPayment Required. sdk.js has no special handling; app.js treats a 402 from a run as "not enough credits" and links to top-up. The page avoids it by comparing /me credits with the estimate first.Top up at https://skillsafe.ai/account/billing/, or check credits against min_credits before running.
403sdk.js comments use it for "not granted" (User Drive) and for a private reference declared access: "run". Neither is used by this app's run lanes.—
404sdk.js maps a 404 on per-user data and collection reads to "not found" (it returns null).—
429 — HTTP semanticsToo Many Requests. sdk.js has no retry or back-off.Wait, then retry with the same Idempotency-Key.
SSE event: errorsdk.js _readSse: data {code, message, job_id}; throws with code and job_id (no HTTP status: the stream itself was a 200).Show message; GET /jobs/{job_id} for the job's final state.
job status: "failed"A terminal job state (waitForJob stops on succeeded or failed); details in job.error.Show error; a new attempt needs a new Idempotency-Key.
"job timed out"Client side only: sdk.js waitForJob gives up after 180 s of 1 s polls. The job may still finish.Keep polling GET /jobs/{job_id}.
model_withdrawn (410), model_unknown, model_integrityThe only error codes sdk.js itself names; all belong to the in-browser model loader (ss.models), not to /v1/app-api.Not relevant to this API.

Idempotency-Key

sdk.js sends Idempotency-Key on POST /run (ss.run(input, key)) and POST /run-stream (opts.idempotencyKey), only when you give one. The page derives it from the app, the lane, a hash of the exact input and the attempt number (llms-desk:create:<hash>:a1): its comment says a network blip then never double-bills, the same material in two lanes is two runs, and the reformat retry is a distinct run (:a2). Do the same: one key per attempt, reused only to repeat that attempt. sdk.js also notes that /run-stream "falls back to a plain JSON result on idempotent replays": the response is then not text/event-stream but an ordinary {"data": ...} envelope, and the snippets in step 6 handle both. Nothing in sdk.js says how long a key is remembered.

Estimate, hold and charge

Step by step

Each step builds on the previous ones (the helper from step 1, the INPUT from step 4). The tabs switch every code block on the page. Replace YOUR_TOKEN, or set SKILLSAFE_TOKEN in your shell, which is what "Copy shell export" on the tokens page gives you.

1. A tiny client helper

One function that makes the same request sdk.js makes: JSON body, bearer token, optional Idempotency-Key, and the {data} / {error} envelope unwrapped. Java uses Jackson for JSON; every other language uses only its standard library.

# Base URL and token. SKILLSAFE_TOKEN is what "Copy shell export" on /tokens.html sets.
API="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"

# ss METHOD PATH [extra curl args...]
# Sends the same two headers sdk.js sends on every call; prints the raw envelope,
# {"data": ...} on success or {"error": {"code", "message", "details"}} on failure.
ss() {
  curl -sS -X "$1" "$API$2" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" \
    "${@:3}"
}

# jq pulls fields out of the envelope, e.g.:
ss GET /me | jq '.data // .error'

2. Get a token

Guest: POST /guest with {"slug": "llms-desk"} and no Authorization header, exactly as sdk.js guest() does; data is {token, guest_id}. A guest token covers /me and /estimate. Personal: the metered lanes need one. Sign in on the app (the SkillSafe sign-in hands the page a token scoped to llms-desk), then open /tokens.html on the same origin to reveal or copy it. Treat it like a password.

# Guest token: the call sdk.js guest() makes. No Authorization header.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"llms-desk"}'
# -> {"data":{"token":"...","guest_id":"..."}}

TOKEN="$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" -d '{"slug":"llms-desk"}' | jq -r '.data.token')"

# Personal token (needed for /run and /run-stream): sign in on
# https://llms-desk.skillsafe.ai/tokens.html, press "Copy shell export", paste it here:
export SKILLSAFE_TOKEN="YOUR_TOKEN"

3. Who am I: GET /me

data is {subject_type, subject_id, credits, profile?} (sdk.js). The page treats subject_type === "user" as signed in; compare credits with the estimate before a run.

ss GET /me | jq '.data'
# -> {"subject_type":"user","subject_id":"...","credits":123456, ...}
#    subject_type "user" = personal token; anything else (a guest) cannot start metered runs

4. Price a run: POST /estimate

Build the run body, then price it. Free, no job, and no validation of the body (see above). The code builds a small create body with facts serialised to a string; curl posts the worked example saved as create.json. For an update, send {task: "update", llms, facts, changes?} the same way.

# create.json = the "create" body from the worked example above (save it to a file).
# /estimate takes exactly the body you would send to /run.
ss POST /estimate --data @create.json | jq '.data'
# -> {"hold_credits": ..., "min_credits": ..., "sponsor_enabled": false, ...}

# The update lane is priced the same way:
ss POST /estimate --data @update.json | jq '.data.hold_credits'

5. Run it: POST /run, then poll GET /jobs/{job_id}

/run is metered and needs a personal token. It returns {job_id}; poll GET /jobs/{job_id} (sdk.js waitForJob: every 1 s, give up after 180 s) until status is succeeded or failed. Then decode the reply string at output.output.

# One Idempotency-Key per attempt: a retry of the SAME attempt after a network error
# reuses it (no double charge); a deliberate new run gets a new key.
KEY="llms-desk:create:sluicewren-demo:a1"

JOB_ID="$(ss POST /run -H "Idempotency-Key: $KEY" --data @create.json | jq -r '.data.job_id')"

# Poll until the job is terminal (succeeded or failed), like sdk.js waitForJob (1 s, 180 s cap).
for i in $(seq 1 180); do
  JOB="$(ss GET "/jobs/$JOB_ID")"
  STATUS="$(printf '%s' "$JOB" | jq -r '.data.status')"
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 1
done
printf '%s' "$JOB" | jq '{status: .data.status, charged_credits: .data.charged_credits, truncated: .data.truncated, error: .data.error}'

# The model's reply is a JSON object inside a STRING at data.output.output: decode it twice.
printf '%s' "$JOB" | jq -r '.data.output.output' | jq '{task, headline, sections: [.sections[].name]}'

6. Stream it: POST /run-stream

Same body, same headers, same metering (personal token). The response is text/event-stream: events separated by a blank line, each with an event: line and data: JSON. sdk.js reads five names: job (the started job), delta ({text}, a piece of the reply), done (the final {job_id, status, charged_credits, output}), pending (kept as the final payload like done) and error ({code, message, job_id}); anything else is ignored. If the response is not text/event-stream it is a plain envelope: an idempotent replay's result, or an error. When the final payload's status is not terminal, poll GET /jobs/{job_id} as in step 5 (this reading of pending is an inference: sdk.js stores it but does not say what it means).

Browsers get ticks, not deltas. The app's own comment (app.js) says that in a browser /run-stream sends tick events rather than delta text, so a page's progress runs on elapsed time and the reply arrives only in the final done payload. sdk.js is consistent with this: it has no tick handler and silently skips such events, and it always takes the reply from done.output.output when present. From curl or a server you receive delta events; either way, prefer done.output.output and fall back to the joined deltas.

# -N turns off curl's buffering so events print as they arrive.
curl -N -sS -X POST "$API/run-stream" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: llms-desk:create:sluicewren-demo:s1" \
  --data @create.json

# Server-Sent Events, one blank line between events. The event names sdk.js reads:
#   event: job     data: {...the job that was started...}
#   event: delta   data: {"text":"{\"task\":\"create\",\"headline\":..."}
#   event: done    data: {"job_id":"...","status":"succeeded","charged_credits":...,"output":{"output":"..."}}
#   event: error   data: {"code":"...","message":"...","job_id":"..."}
# ("pending" in place of "done" is also a final payload - see below.)
# If the response is NOT text/event-stream it is a plain {"data": ...} or {"error": ...}
# envelope: sdk.js returns that as the result (an idempotent replay) or throws on an error.

What the page adds on top

After a reply, the page's recon.js re-checks it against the input: every link must come from the page list, the current file or a URL written in about / changes; every page answered exactly once (create); every finding answered exactly once, no error waved away, a fixed finding gone from the new file (update); and the returned file is run through the same format checker. Over the API you get the reply alone, so run those checks yourself before publishing a file.