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 · Input fields · The facts string
- The reply · Worked examples
- Base URL, envelope, errors · Idempotency-Key · Estimate and credits
- Step by step: helper, token, /me, /estimate, /run, /run-stream
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.
| task | What it does | Send | Reply 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, name | task, 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 changes | task, 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.
| Field | Type | Lane | Meaning | Page limit |
|---|---|---|---|---|
task | string | both | "create" or "update". | — |
facts | string (JSON) | both | A 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. |
about | string | create, optional | What 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]. |
name | string | create, optional | The project or site name, used as the H1. | 120 characters, trimmed. |
llms | string | update | The current llms.txt, whole, with line endings normalised to \n. | The page refuses files over 24,000 characters (an update returns the whole file). |
changes | string | update, optional | What 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_note | string | both, optional | Send 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_note | string | both, optional | Not 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)
| Key | Value |
|---|---|
pages | Array 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_total | How many pages the list had in total. |
page_list_format | How the list was read, e.g. "sitemap". |
base_url | Where the file will live (e.g. https://sluicewren.example/llms.txt), or "". |
pages_not_sent | Only when the list was cut: an object of reason to count, e.g. {"over the limit": 40}. |
task "update" (deskkit.js updateFacts)
| Key | Value |
|---|---|
checker | A string naming the checker and its rule sets. |
status | The checker's verdict on the current file: broken (an error), fix_first (warnings), ok. |
file_url | The file's address, or "". |
findings | At 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_total | How many findings the checker had (may exceed the 60 sent). |
links | At most 300 of {id, section, title, url}, ids L1, L2, ... |
links_total | How many links the file has. |
reference_parser | "reads the file" or "throws: ..." with the reason. |
pages, pages_total, pages_not_sent | Only when you also gave a current page list: as for create. |
coverage | Only 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
| Key | Type | Meaning (SKILL.md) | How recon.js normalize() reads it |
|---|---|---|---|
task | string | "create". | Lower-cased; if it names the other lane, that lane's shape is used and flagged as a mismatch. |
headline | string | One sentence: what the file covers and how it is organised. | Stringified. |
llms_txt | string | The 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. |
assumptions | string[] | Each guess the input did not state. | First 20, empty strings dropped. |
to_check | string[] | What to verify before publishing. | First 20. |
task "update" reply
| Key | Type | Meaning (SKILL.md) | How recon.js normalize() reads it |
|---|---|---|---|
task | string | "update". | As for create. |
headline | string | One sentence: what was out of date or wrong and what the updated file does. | Stringified. |
verdict | string | On 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_txt | string or null | The complete updated file (not a diff), or null when nothing needs to change. | Read like llms_txt; null means no file. |
to_check | string[] | 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:
Content-Type: application/jsonon every call, with a JSON body where there is one;Authorization: Bearer <token>whenever it holds a token (every call exceptPOST /guest, which it makes only when it has none);Idempotency-Key: <key>onPOST /runandPOST /run-streamwhen the caller passes a key.
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.
| Call | Body | data | Starts a metered run? |
|---|---|---|---|
POST /guest | {"slug": "llms-desk"} | {token, guest_id} | no |
GET /me | — | {subject_type, subject_id, credits, profile?} | no |
POST /estimate | the run body | the page reads hold_credits, min_credits, sponsor_enabled | no (sdk.js: "no charge, no job") |
POST /run | the run body | {job_id, ...} | yes |
GET /jobs/{job_id} | — | the job: status, output.output, charged_credits, truncated, error | no (reads a job) |
POST /run-stream | the run body | Server-Sent Events (see step 6), or a plain envelope | yes |
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 / signal | Where it comes from | What 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 semantics | Unauthorized: 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 semantics | Payment 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. |
| 403 | sdk.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. | — |
| 404 | sdk.js maps a 404 on per-user data and collection reads to "not found" (it returns null). | — |
| 429 — HTTP semantics | Too Many Requests. sdk.js has no retry or back-off. | Wait, then retry with the same Idempotency-Key. |
SSE event: error | sdk.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_integrity | The 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
POST /estimateis free: "worst-case credit cost of a run with this input (no charge, no job)". It takes the same body as/run, and a guest token is enough.- It does not validate the body.
sdk.jsposts whatever you pass (an empty object if you pass nothing) anddeskkit.jsnotes it "validates nothing". A price is not proof the body is right: checktask, the lane's required fields and thatfactsis a string yourself. hold_creditsis what a run reserves andmin_creditsthe minimum it needs; the page says you are charged only for what the run uses, usually far less, and the job reportscharged_credits. A balance under the hold can cut the reply short (truncated: true); the page will not start a run below the minimum. The page prices credits at 10,000 to the US dollar (1 credit = $0.0001)./runand/run-streamare metered and need a personal (signed-in) token (/mesubject_type: "user"). A guest token can call/meand/estimateonly; the page sends guests to sign in unless the estimate reportssponsor_enabled: true.
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'
# skillsafe.py - standard library only (Python 3.8+)
import json
import os
import time
import urllib.error
import urllib.parse
import urllib.request
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN")
class ApiError(Exception):
def __init__(self, status, code, message, details=None):
super().__init__("%s %s: %s" % (status, code, message))
self.status, self.code, self.details = status, code, details
def call(method, path, body=None, idempotency_key=None, token=None):
"""One request; returns the envelope's `data`, raises ApiError on a non-2xx."""
token = TOKEN if token is None else token
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = "Bearer " + token
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
data = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(API + path, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req) as res:
return json.load(res).get("data")
except urllib.error.HTTPError as e:
try:
err = json.load(e).get("error") or {}
except ValueError:
err = {}
raise ApiError(e.code, err.get("code"), err.get("message") or e.reason,
err.get("details")) from None
// skillsafe.mjs - Node 18+ or any browser (global fetch)
export const API = "https://api.skillsafe.ai/v1/app-api";
export let TOKEN = "YOUR_TOKEN"; // paste from /tokens.html, or load it from your secret store
export function setToken(t) { TOKEN = t; }
// The same request sdk.js makes: JSON body, bearer token, {data}/{error} envelope.
export async function call(method, path, body, idempotencyKey) {
const headers = { "Content-Type": "application/json" };
if (TOKEN) headers["Authorization"] = "Bearer " + TOKEN;
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(API + path, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error((json.error && json.error.message) || res.statusText);
err.status = res.status;
err.code = json.error && json.error.code;
err.details = json.error && json.error.details;
throw err;
}
return json.data;
}
// skillsafe.go - standard library only (Go 1.18+)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const api = "https://api.skillsafe.ai/v1/app-api"
var token = envOr("SKILLSAFE_TOKEN", "YOUR_TOKEN")
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
type APIError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}
func (e *APIError) Error() string { return fmt.Sprintf("%d %s: %s", e.Status, e.Code, e.Message) }
// call sends one request and decodes the envelope's data into out (may be nil).
func call(method, path string, body any, idemKey string, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, api+path, rdr)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if idemKey != "" {
req.Header.Set("Idempotency-Key", idemKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env struct {
Data json.RawMessage `json:"data"`
Error *APIError `json:"error"`
}
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
e := env.Error
if e == nil {
e = &APIError{Message: res.Status}
}
e.Status = res.StatusCode
return e
}
if out != nil && len(env.Data) > 0 {
return json.Unmarshal(env.Data, out)
}
return nil
}
// SkillSafe.java - java.net.http (Java 11+) plus Jackson for JSON
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SkillSafe {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient HTTP = HttpClient.newHttpClient();
static final ObjectMapper JSON = new ObjectMapper();
static String token = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static class ApiException extends RuntimeException {
final int status;
final String code;
final JsonNode details;
ApiException(int status, String code, String message, JsonNode details) {
super(status + " " + code + ": " + message);
this.status = status;
this.code = code;
this.details = details;
}
}
static HttpRequest.Builder request(String method, String path, Object body, String idempotencyKey)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(API + path))
.header("Content-Type", "application/json");
if (token != null && !token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (idempotencyKey != null) b.header("Idempotency-Key", idempotencyKey);
HttpRequest.BodyPublisher pub = body == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body));
return b.method(method, pub);
}
/** One request; returns the envelope's data, throws ApiException on a non-2xx. */
static JsonNode call(String method, String path, Object body, String idempotencyKey) throws Exception {
HttpResponse<String> res = HTTP.send(request(method, path, body, idempotencyKey).build(),
HttpResponse.BodyHandlers.ofString());
return unwrap(res.statusCode(), res.body());
}
static JsonNode unwrap(int status, String text) {
JsonNode env;
try {
env = JSON.readTree(text == null || text.isEmpty() ? "{}" : text);
} catch (Exception e) {
env = JSON.createObjectNode();
}
if (status / 100 != 2) {
JsonNode err = env.path("error");
throw new ApiException(status, err.path("code").asText(null),
err.path("message").asText("HTTP " + status), err.get("details"));
}
return env.path("data");
}
}
# skillsafe.rb - standard library only
require "json"
require "net/http"
require "uri"
API = "https://api.skillsafe.ai/v1/app-api"
$token = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN")
class ApiError < StandardError
attr_reader :status, :code, :details
def initialize(status, code, message, details)
super("#{status} #{code}: #{message}")
@status = status
@code = code
@details = details
end
end
def build_request(method, path, body: nil, idempotency_key: nil, token: $token)
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri) # Net::HTTP::Get, ::Post, ...
req["Content-Type"] = "application/json"
req["Authorization"] = "Bearer #{token}" if token && !token.empty?
req["Idempotency-Key"] = idempotency_key if idempotency_key
req.body = JSON.generate(body) unless body.nil?
[uri, req]
end
# One request (body: is keyword-only so a symbol-keyed Hash is never read as options);
# returns the envelope's "data", raises ApiError on a non-2xx.
def call(method, path, body: nil, idempotency_key: nil, token: $token)
uri, req = build_request(method, path, body: body, idempotency_key: idempotency_key, token: token)
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(req) }
env = begin
JSON.parse(res.body.to_s)
rescue JSON::ParserError
{}
end
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message, err["details"])
end
env["data"]
end
<?php
// skillsafe.php - ext-curl and ext-json (PHP 7.4+)
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN";
class ApiException extends RuntimeException {
public $status; public $apiCode; public $details;
public function __construct(int $status, ?string $code, string $message, $details = null) {
parent::__construct("$status $code: $message");
$this->status = $status; $this->apiCode = $code; $this->details = $details;
}
}
function headers(?string $token, ?string $idempotencyKey): array {
$h = ["Content-Type: application/json"];
if ($token !== null && $token !== "") $h[] = "Authorization: Bearer " . $token;
if ($idempotencyKey !== null) $h[] = "Idempotency-Key: " . $idempotencyKey;
return $h;
}
function unwrap(int $status, string $raw) {
$env = json_decode($raw, true);
if (!is_array($env)) $env = [];
if ($status < 200 || $status > 299) {
$err = $env["error"] ?? [];
throw new ApiException($status, $err["code"] ?? null, $err["message"] ?? "HTTP $status", $err["details"] ?? null);
}
return $env["data"] ?? null;
}
/** One request; returns the envelope's data, throws ApiException on a non-2xx. Pass $token = "" for none. */
function call(string $method, string $path, $body = null, ?string $idempotencyKey = null, ?string $token = null) {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => headers($token ?? $TOKEN, $idempotencyKey),
CURLOPT_RETURNTRANSFER => true,
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$raw = curl_exec($ch);
if ($raw === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
return unwrap($status, $raw);
}
// SkillSafe.cs - .NET 6+ (HttpClient + System.Text.Json)
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
public static class SkillSafe
{
public const string Api = "https://api.skillsafe.ai/v1/app-api";
public static readonly HttpClient Http = new HttpClient();
public static string? Token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
public class ApiException : Exception
{
public int Status { get; }
public string? Code { get; }
public JsonNode? Details { get; }
public ApiException(int status, string? code, string message, JsonNode? details)
: base($"{status} {code}: {message}")
{
Status = status; Code = code; Details = details;
}
}
public static HttpRequestMessage Request(string method, string path, object? body = null,
string? idempotencyKey = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Api + path);
if (!string.IsNullOrEmpty(Token))
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (idempotencyKey != null) req.Headers.Add("Idempotency-Key", idempotencyKey);
if (body != null)
{
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8);
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
}
return req;
}
public static JsonNode? Unwrap(int status, string text)
{
JsonNode? env = null;
try { env = JsonNode.Parse(text); } catch (JsonException) { }
if (status < 200 || status > 299)
{
var err = env?["error"];
throw new ApiException(status, err?["code"]?.GetValue<string>(),
err?["message"]?.GetValue<string>() ?? "HTTP " + status, err?["details"]);
}
return env?["data"];
}
/// <summary>One request; returns the envelope's data, throws ApiException on a non-2xx.</summary>
public static async Task<JsonNode?> Call(string method, string path, object? body = null,
string? idempotencyKey = null)
{
using var req = Request(method, path, body, idempotencyKey);
using var res = await Http.SendAsync(req);
return Unwrap((int)res.StatusCode, await res.Content.ReadAsStringAsync());
}
}
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"
# Guest token: POST /guest with the app slug and no Authorization header.
guest = call("POST", "/guest", {"slug": "llms-desk"}, token="")
TOKEN = guest["token"] # also returned: guest["guest_id"]
# Personal token: sign in on https://llms-desk.skillsafe.ai/tokens.html, copy it,
# and run with SKILLSAFE_TOKEN set - step 1 reads it from os.environ.
import { call, setToken } from "./skillsafe.mjs";
// Guest token: POST /guest with the app slug. sdk.js sends no Authorization header here.
setToken(null);
const guest = await call("POST", "/guest", { slug: "llms-desk" });
setToken(guest.token); // guest.guest_id is returned too
// Personal token: sign in on https://llms-desk.skillsafe.ai/tokens.html and paste it:
// setToken("YOUR_TOKEN");
// Guest token: POST /guest with the app slug and no Authorization header.
func guestToken() error {
saved := token
token = ""
var g struct {
Token string `json:"token"`
GuestID string `json:"guest_id"`
}
err := call("POST", "/guest", map[string]string{"slug": "llms-desk"}, "", &g)
if err != nil {
token = saved
return err
}
token = g.Token
return nil
}
// Personal token: sign in on https://llms-desk.skillsafe.ai/tokens.html, copy it,
// and export SKILLSAFE_TOKEN before running - step 1 reads it with os.Getenv.
// Guest token: POST /guest with the app slug and no Authorization header.
SkillSafe.token = null;
JsonNode guest = SkillSafe.call("POST", "/guest", java.util.Map.of("slug", "llms-desk"), null);
SkillSafe.token = guest.path("token").asText(); // guest.path("guest_id") is returned too
// Personal token: sign in on https://llms-desk.skillsafe.ai/tokens.html, copy it,
// and set SKILLSAFE_TOKEN before starting the JVM - step 1 reads System.getenv().
# Guest token: POST /guest with the app slug and no Authorization header.
guest = call("POST", "/guest", body: { slug: "llms-desk" }, token: nil)
$token = guest["token"] # guest["guest_id"] is returned too
# Personal token: sign in on https://llms-desk.skillsafe.ai/tokens.html, copy it,
# and set SKILLSAFE_TOKEN - step 1 reads ENV.
<?php
// Guest token: POST /guest with the app slug and no Authorization header ($token = "").
$guest = call("POST", "/guest", ["slug" => "llms-desk"], null, "");
$TOKEN = $guest["token"]; // $guest["guest_id"] is returned too
// Personal token: sign in on https://llms-desk.skillsafe.ai/tokens.html, copy it,
// and set SKILLSAFE_TOKEN - step 1 reads it with getenv().
// Guest token: POST /guest with the app slug and no Authorization header.
SkillSafe.Token = null;
var guest = await SkillSafe.Call("POST", "/guest", new { slug = "llms-desk" });
SkillSafe.Token = guest?["token"]?.GetValue<string>(); // guest["guest_id"] is returned too
// Personal token: sign in on https://llms-desk.skillsafe.ai/tokens.html, copy it,
// and set SKILLSAFE_TOKEN - step 1 reads Environment.GetEnvironmentVariable.
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
me = call("GET", "/me")
print(me["subject_type"], me.get("credits"))
if me["subject_type"] != "user":
print("guest token: /estimate works, /run and /run-stream need a personal token")
import { call } from "./skillsafe.mjs";
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
if (me.subject_type !== "user") console.log("guest token: /estimate works, /run and /run-stream need a personal token");
func whoAmI() error {
var me struct {
SubjectType string `json:"subject_type"`
SubjectID any `json:"subject_id"`
Credits *float64 `json:"credits"`
}
if err := call("GET", "/me", nil, "", &me); err != nil {
return err
}
fmt.Println(me.SubjectType, me.SubjectID)
if me.Credits != nil {
fmt.Println("credits:", *me.Credits)
}
if me.SubjectType != "user" {
fmt.Println("guest token: /estimate works, /run and /run-stream need a personal token")
}
return nil
}
JsonNode me = SkillSafe.call("GET", "/me", null, null);
System.out.println(me.path("subject_type").asText() + " " + me.path("credits").asText("-"));
if (!"user".equals(me.path("subject_type").asText())) {
System.out.println("guest token: /estimate works, /run and /run-stream need a personal token");
}
me = call("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
puts "guest token: /estimate works, /run and /run-stream need a personal token" unless me["subject_type"] == "user"
<?php
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "-", "\n";
if ($me["subject_type"] !== "user") {
echo "guest token: /estimate works, /run and /run-stream need a personal token\n";
}
var me = await SkillSafe.Call("GET", "/me");
var subjectType = me?["subject_type"]?.GetValue<string>();
Console.WriteLine($"{subjectType} {me?["credits"]}");
if (subjectType != "user")
Console.WriteLine("guest token: /estimate works, /run and /run-stream need a personal token");
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'
# facts is a JSON object serialised to a STRING - the same shape deskkit.js createFacts() builds.
facts = {
"pages": [
{"id": "P1", "url": "https://sluicewren.example/docs/quickstart", "title": "Quickstart",
"kind": "examples", "md_url": "https://sluicewren.example/docs/quickstart.md", "title_from_path": True},
{"id": "P2", "url": "https://sluicewren.example/docs/reference/api", "title": "API",
"kind": "reference", "title_from_path": True},
{"id": "P3", "url": "https://sluicewren.example/login", "title": "Login", "kind": "other",
"title_from_path": True, "skip": "account or search page"},
],
"pages_total": 3,
"page_list_format": "sitemap",
"base_url": "https://sluicewren.example/llms.txt",
}
INPUT = {
"task": "create",
"facts": json.dumps(facts),
"about": "Sluicewren is an open-source stream-processing library for TypeScript and Node.js.",
"name": "Sluicewren",
}
est = call("POST", "/estimate", INPUT) # free: no charge, no job
print("hold", est.get("hold_credits"), "min", est.get("min_credits"),
"sponsored", est.get("sponsor_enabled"))
import { call } from "./skillsafe.mjs";
// facts is a JSON object serialised to a STRING - the same shape deskkit.js createFacts() builds.
const facts = {
pages: [
{ id: "P1", url: "https://sluicewren.example/docs/quickstart", title: "Quickstart", kind: "examples",
md_url: "https://sluicewren.example/docs/quickstart.md", title_from_path: true },
{ id: "P2", url: "https://sluicewren.example/docs/reference/api", title: "API", kind: "reference", title_from_path: true },
{ id: "P3", url: "https://sluicewren.example/login", title: "Login", kind: "other", title_from_path: true,
skip: "account or search page" },
],
pages_total: 3,
page_list_format: "sitemap",
base_url: "https://sluicewren.example/llms.txt",
};
export const INPUT = {
task: "create",
facts: JSON.stringify(facts),
about: "Sluicewren is an open-source stream-processing library for TypeScript and Node.js.",
name: "Sluicewren",
};
const est = await call("POST", "/estimate", INPUT); // free: no charge, no job
console.log("hold", est.hold_credits, "min", est.min_credits, "sponsored", est.sponsor_enabled);
// createInput builds the "create" body. facts is a JSON object serialised to a STRING.
func createInput() (map[string]any, error) {
facts := map[string]any{
"pages": []map[string]any{
{"id": "P1", "url": "https://sluicewren.example/docs/quickstart", "title": "Quickstart",
"kind": "examples", "md_url": "https://sluicewren.example/docs/quickstart.md", "title_from_path": true},
{"id": "P2", "url": "https://sluicewren.example/docs/reference/api", "title": "API",
"kind": "reference", "title_from_path": true},
{"id": "P3", "url": "https://sluicewren.example/login", "title": "Login", "kind": "other",
"title_from_path": true, "skip": "account or search page"},
},
"pages_total": 3,
"page_list_format": "sitemap",
"base_url": "https://sluicewren.example/llms.txt",
}
b, err := json.Marshal(facts)
if err != nil {
return nil, err
}
return map[string]any{
"task": "create",
"facts": string(b),
"about": "Sluicewren is an open-source stream-processing library for TypeScript and Node.js.",
"name": "Sluicewren",
}, nil
}
func estimate(input map[string]any) error {
var est map[string]any // hold_credits, min_credits, sponsor_enabled, ...
if err := call("POST", "/estimate", input, "", &est); err != nil {
return err
}
fmt.Println("hold", est["hold_credits"], "min", est["min_credits"], "sponsored", est["sponsor_enabled"])
return nil
}
// imports: java.util.LinkedHashMap, java.util.List, java.util.Map
// facts is a JSON object serialised to a STRING - the same shape deskkit.js createFacts() builds.
Map<String, Object> facts = new LinkedHashMap<>();
facts.put("pages", List.of(
Map.of("id", "P1", "url", "https://sluicewren.example/docs/quickstart", "title", "Quickstart",
"kind", "examples", "md_url", "https://sluicewren.example/docs/quickstart.md", "title_from_path", true),
Map.of("id", "P2", "url", "https://sluicewren.example/docs/reference/api", "title", "API",
"kind", "reference", "title_from_path", true),
Map.of("id", "P3", "url", "https://sluicewren.example/login", "title", "Login", "kind", "other",
"title_from_path", true, "skip", "account or search page")));
facts.put("pages_total", 3);
facts.put("page_list_format", "sitemap");
facts.put("base_url", "https://sluicewren.example/llms.txt");
Map<String, Object> input = new LinkedHashMap<>();
input.put("task", "create");
input.put("facts", SkillSafe.JSON.writeValueAsString(facts));
input.put("about", "Sluicewren is an open-source stream-processing library for TypeScript and Node.js.");
input.put("name", "Sluicewren");
JsonNode est = SkillSafe.call("POST", "/estimate", input, null); // free: no charge, no job
System.out.println("hold " + est.path("hold_credits") + " min " + est.path("min_credits")
+ " sponsored " + est.path("sponsor_enabled"));
# facts is a JSON object serialised to a STRING - the same shape deskkit.js createFacts() builds.
facts = {
pages: [
{ id: "P1", url: "https://sluicewren.example/docs/quickstart", title: "Quickstart", kind: "examples",
md_url: "https://sluicewren.example/docs/quickstart.md", title_from_path: true },
{ id: "P2", url: "https://sluicewren.example/docs/reference/api", title: "API", kind: "reference",
title_from_path: true },
{ id: "P3", url: "https://sluicewren.example/login", title: "Login", kind: "other",
title_from_path: true, skip: "account or search page" }
],
pages_total: 3,
page_list_format: "sitemap",
base_url: "https://sluicewren.example/llms.txt"
}
INPUT = {
task: "create",
facts: JSON.generate(facts),
about: "Sluicewren is an open-source stream-processing library for TypeScript and Node.js.",
name: "Sluicewren"
}.freeze
est = call("POST", "/estimate", body: INPUT) # free: no charge, no job
puts "hold #{est["hold_credits"]} min #{est["min_credits"]} sponsored #{est["sponsor_enabled"]}"
<?php
// facts is a JSON object serialised to a STRING - the same shape deskkit.js createFacts() builds.
$facts = [
"pages" => [
["id" => "P1", "url" => "https://sluicewren.example/docs/quickstart", "title" => "Quickstart",
"kind" => "examples", "md_url" => "https://sluicewren.example/docs/quickstart.md", "title_from_path" => true],
["id" => "P2", "url" => "https://sluicewren.example/docs/reference/api", "title" => "API",
"kind" => "reference", "title_from_path" => true],
["id" => "P3", "url" => "https://sluicewren.example/login", "title" => "Login", "kind" => "other",
"title_from_path" => true, "skip" => "account or search page"],
],
"pages_total" => 3,
"page_list_format" => "sitemap",
"base_url" => "https://sluicewren.example/llms.txt",
];
$INPUT = [
"task" => "create",
"facts" => json_encode($facts, JSON_UNESCAPED_SLASHES),
"about" => "Sluicewren is an open-source stream-processing library for TypeScript and Node.js.",
"name" => "Sluicewren",
];
$est = call("POST", "/estimate", $INPUT); // free: no charge, no job
echo "hold ", $est["hold_credits"] ?? "-", " min ", $est["min_credits"] ?? "-",
" sponsored ", var_export($est["sponsor_enabled"] ?? null, true), "\n";
// facts is a JSON object serialised to a STRING - the same shape deskkit.js createFacts() builds.
var facts = new
{
pages = new object[]
{
new { id = "P1", url = "https://sluicewren.example/docs/quickstart", title = "Quickstart",
kind = "examples", md_url = "https://sluicewren.example/docs/quickstart.md", title_from_path = true },
new { id = "P2", url = "https://sluicewren.example/docs/reference/api", title = "API",
kind = "reference", title_from_path = true },
new { id = "P3", url = "https://sluicewren.example/login", title = "Login", kind = "other",
title_from_path = true, skip = "account or search page" },
},
pages_total = 3,
page_list_format = "sitemap",
base_url = "https://sluicewren.example/llms.txt",
};
var input = new
{
task = "create",
facts = JsonSerializer.Serialize(facts),
about = "Sluicewren is an open-source stream-processing library for TypeScript and Node.js.",
name = "Sluicewren",
};
var est = await SkillSafe.Call("POST", "/estimate", input); // free: no charge, no job
Console.WriteLine($"hold {est?["hold_credits"]} min {est?["min_credits"]} sponsored {est?["sponsor_enabled"]}");
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]}'
def parse_reply(text):
"""The reply is one JSON object in a string; tolerate a stray code fence like recon.js does."""
start, end = text.find("{"), text.rfind("}")
if start < 0 or end < start:
raise ValueError("no JSON object in the reply")
return json.loads(text[start:end + 1])
def wait_for_job(job_id, interval=1.0, timeout=180.0):
deadline = time.time() + timeout
while True:
job = call("GET", "/jobs/" + urllib.parse.quote(job_id, safe=""))
if job["status"] in ("succeeded", "failed"):
return job
if time.time() > deadline:
raise TimeoutError("job timed out")
time.sleep(interval)
key = "llms-desk:create:sluicewren-demo:a1" # one key per attempt; reuse it only to retry that attempt
started = call("POST", "/run", INPUT, idempotency_key=key)
job = wait_for_job(started["job_id"])
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
reply = parse_reply(job["output"]["output"])
print(job.get("charged_credits"), "credits;", "cut short" if job.get("truncated") else "complete")
print(reply["headline"])
print(reply["llms_txt"]) # "create"; an "update" reply carries "updated_llms_txt"
import { call } from "./skillsafe.mjs";
import { INPUT } from "./step4.mjs";
// The reply is one JSON object in a string; tolerate a stray code fence like recon.js does.
function parseReply(text) {
const start = text.indexOf("{"), end = text.lastIndexOf("}");
if (start < 0 || end < start) throw new Error("no JSON object in the reply");
return JSON.parse(text.slice(start, end + 1));
}
async function waitForJob(jobId, intervalMs = 1000, timeoutMs = 180000) {
const t0 = Date.now();
for (;;) {
const job = await call("GET", "/jobs/" + encodeURIComponent(jobId));
if (job.status === "succeeded" || job.status === "failed") return job;
if (Date.now() - t0 > timeoutMs) throw new Error("job timed out");
await new Promise((r) => setTimeout(r, intervalMs));
}
}
const key = "llms-desk:create:sluicewren-demo:a1"; // one key per attempt; reuse it only to retry that attempt
const { job_id } = await call("POST", "/run", INPUT, key);
const job = await waitForJob(job_id);
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const reply = parseReply(job.output.output);
console.log(job.charged_credits, "credits;", job.truncated ? "cut short" : "complete");
console.log(reply.headline);
console.log(reply.llms_txt); // "create"; an "update" reply carries updated_llms_txt
// also import "net/url", "strings" and "time"
type Job struct {
Status string `json:"status"`
ChargedCredits *float64 `json:"charged_credits"`
Truncated bool `json:"truncated"`
Error json.RawMessage `json:"error"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
func waitForJob(jobID string) (*Job, error) {
deadline := time.Now().Add(180 * time.Second)
for {
var job Job
if err := call("GET", "/jobs/"+url.PathEscape(jobID), nil, "", &job); err != nil {
return nil, err
}
if job.Status == "succeeded" || job.Status == "failed" {
return &job, nil
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("job timed out")
}
time.Sleep(time.Second)
}
}
// parseReply: the reply is one JSON object in a string; tolerate a stray code fence.
func parseReply(text string, out any) error {
start, end := strings.Index(text, "{"), strings.LastIndex(text, "}")
if start < 0 || end < start {
return fmt.Errorf("no JSON object in the reply")
}
return json.Unmarshal([]byte(text[start:end+1]), out)
}
func runAndWait(input map[string]any) error {
key := "llms-desk:create:sluicewren-demo:a1" // one key per attempt; reuse it only to retry that attempt
var started struct {
JobID string `json:"job_id"`
}
if err := call("POST", "/run", input, key, &started); err != nil {
return err
}
job, err := waitForJob(started.JobID)
if err != nil {
return err
}
if job.Status == "failed" {
return fmt.Errorf("job failed: %s", job.Error)
}
var reply map[string]any
if err := parseReply(job.Output.Output, &reply); err != nil {
return err
}
fmt.Println("truncated:", job.Truncated)
fmt.Println(reply["headline"])
fmt.Println(reply["llms_txt"]) // "create"; an "update" reply carries "updated_llms_txt"
return nil
}
// The reply is one JSON object in a string; tolerate a stray code fence like recon.js does.
static JsonNode parseReply(String text) throws Exception {
int start = text.indexOf('{'), end = text.lastIndexOf('}');
if (start < 0 || end < start) throw new IllegalStateException("no JSON object in the reply");
return SkillSafe.JSON.readTree(text.substring(start, end + 1));
}
static JsonNode waitForJob(String jobId) throws Exception {
long deadline = System.currentTimeMillis() + 180_000;
while (true) {
JsonNode job = SkillSafe.call("GET",
"/jobs/" + java.net.URLEncoder.encode(jobId, java.nio.charset.StandardCharsets.UTF_8), null, null);
String status = job.path("status").asText();
if (status.equals("succeeded") || status.equals("failed")) return job;
if (System.currentTimeMillis() > deadline) throw new IllegalStateException("job timed out");
Thread.sleep(1000);
}
}
// in main(), with `input` from step 4:
String key = "llms-desk:create:sluicewren-demo:a1"; // one key per attempt; reuse it only to retry that attempt
JsonNode started = SkillSafe.call("POST", "/run", input, key);
JsonNode job = waitForJob(started.path("job_id").asText());
if (job.path("status").asText().equals("failed")) throw new IllegalStateException(job.path("error").toString());
JsonNode reply = parseReply(job.path("output").path("output").asText());
System.out.println(job.path("charged_credits") + " credits; truncated=" + job.path("truncated").asBoolean(false));
System.out.println(reply.path("headline").asText());
System.out.println(reply.path("llms_txt").asText()); // "create"; an "update" reply carries updated_llms_txt
# The reply is one JSON object in a string; tolerate a stray code fence like recon.js does.
def parse_reply(text)
start = text.index("{")
finish = text.rindex("}")
raise "no JSON object in the reply" if start.nil? || finish.nil? || finish < start
JSON.parse(text[start..finish])
end
def wait_for_job(job_id, interval: 1, timeout: 180)
deadline = Time.now + timeout
loop do
job = call("GET", "/jobs/#{URI.encode_www_form_component(job_id)}")
return job if %w[succeeded failed].include?(job["status"])
raise "job timed out" if Time.now > deadline
sleep interval
end
end
key = "llms-desk:create:sluicewren-demo:a1" # one key per attempt; reuse it only to retry that attempt
started = call("POST", "/run", body: INPUT, idempotency_key: key)
job = wait_for_job(started["job_id"])
raise "job failed: #{job["error"].inspect}" if job["status"] == "failed"
reply = parse_reply(job["output"]["output"])
puts "#{job["charged_credits"]} credits; #{job["truncated"] ? "cut short" : "complete"}"
puts reply["headline"]
puts reply["llms_txt"] # "create"; an "update" reply carries "updated_llms_txt"
<?php
// The reply is one JSON object in a string; tolerate a stray code fence like recon.js does.
function parseReply(string $text): array {
$start = strpos($text, "{");
$end = strrpos($text, "}");
if ($start === false || $end === false || $end < $start) throw new RuntimeException("no JSON object in the reply");
return json_decode(substr($text, $start, $end - $start + 1), true, 512, JSON_THROW_ON_ERROR);
}
function waitForJob(string $jobId, int $timeout = 180): array {
$deadline = time() + $timeout;
while (true) {
$job = call("GET", "/jobs/" . rawurlencode($jobId));
if (in_array($job["status"], ["succeeded", "failed"], true)) return $job;
if (time() > $deadline) throw new RuntimeException("job timed out");
sleep(1);
}
}
$key = "llms-desk:create:sluicewren-demo:a1"; // one key per attempt; reuse it only to retry that attempt
$started = call("POST", "/run", $INPUT, $key);
$job = waitForJob($started["job_id"]);
if ($job["status"] === "failed") throw new RuntimeException("job failed: " . json_encode($job["error"] ?? null));
$reply = parseReply($job["output"]["output"]);
echo ($job["charged_credits"] ?? "-"), " credits; ", !empty($job["truncated"]) ? "cut short" : "complete", "\n";
echo $reply["headline"], "\n";
echo $reply["llms_txt"]; // "create"; an "update" reply carries "updated_llms_txt"
// The reply is one JSON object in a string; tolerate a stray code fence like recon.js does.
static JsonNode ParseReply(string text)
{
int start = text.IndexOf('{'), end = text.LastIndexOf('}');
if (start < 0 || end < start) throw new InvalidOperationException("no JSON object in the reply");
return JsonNode.Parse(text.Substring(start, end - start + 1))!;
}
static async Task<JsonNode> WaitForJob(string jobId)
{
var deadline = DateTime.UtcNow.AddSeconds(180);
while (true)
{
var job = (await SkillSafe.Call("GET", "/jobs/" + Uri.EscapeDataString(jobId)))!;
var status = job["status"]?.GetValue<string>();
if (status == "succeeded" || status == "failed") return job;
if (DateTime.UtcNow > deadline) throw new TimeoutException("job timed out");
await Task.Delay(1000);
}
}
// with `input` from step 4:
var key = "llms-desk:create:sluicewren-demo:a1"; // one key per attempt; reuse it only to retry that attempt
var started = await SkillSafe.Call("POST", "/run", input, key);
var job = await WaitForJob(started!["job_id"]!.GetValue<string>());
if (job["status"]?.GetValue<string>() == "failed") throw new Exception($"job failed: {job["error"]?.ToJsonString()}");
var reply = ParseReply(job["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{job["charged_credits"]} credits; truncated={job["truncated"]}");
Console.WriteLine(reply["headline"]);
Console.WriteLine(reply["llms_txt"]); // "create"; an "update" reply carries updated_llms_txt
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.
def run_stream(body, idempotency_key=None, on_delta=None):
"""POST /run-stream; returns the final `done` (or `pending`) payload, like sdk.js runStream."""
headers = {"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
req = urllib.request.Request(API + "/run-stream", data=json.dumps(body).encode("utf-8"),
method="POST", headers=headers)
try:
res = urllib.request.urlopen(req)
except urllib.error.HTTPError as e:
try:
err = json.load(e).get("error") or {}
except ValueError:
err = {}
raise ApiError(e.code, err.get("code"), err.get("message") or e.reason, err.get("details")) from None
with res:
if "text/event-stream" not in (res.headers.get("Content-Type") or ""):
return json.load(res).get("data") # idempotent replay: a plain envelope
result, failure, event, data = None, None, "message", ""
for raw in res: # one line at a time
line = raw.decode("utf-8").rstrip("\r\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
data += line[5:].strip()
elif line == "" and data:
try:
payload = json.loads(data)
except ValueError:
payload = None
if payload is not None:
if event == "delta" and on_delta:
on_delta(payload.get("text", ""))
elif event in ("done", "pending"):
result = payload
elif event == "error":
failure = payload
event, data = "message", ""
if failure:
raise ApiError(None, failure.get("code"), failure.get("message") or "job failed")
return result
text = []
done = run_stream(INPUT, "llms-desk:create:sluicewren-demo:s1", on_delta=text.append)
if done and done.get("status") not in ("succeeded", "failed"):
done = wait_for_job(done["job_id"]) # step 5: finish a "pending" result by polling
full = ((done or {}).get("output") or {}).get("output") or "".join(text)
reply = parse_reply(full)
print(reply["headline"])
import { API, TOKEN } from "./skillsafe.mjs";
import { INPUT } from "./step4.mjs";
// POST /run-stream and read the SSE body the way sdk.js _readSse does.
export async function runStream(body, { idempotencyKey, onDelta, onJob } = {}) {
const headers = { "Content-Type": "application/json" };
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
if (TOKEN) headers["Authorization"] = "Bearer " + TOKEN;
const res = await fetch(API + "/run-stream", { method: "POST", headers, body: JSON.stringify(body) });
if ((res.headers.get("content-type") || "").indexOf("text/event-stream") === -1) {
const json = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error((json.error && json.error.message) || res.statusText),
{ status: res.status, code: json.error && json.error.code });
return json.data; // idempotent replay: a plain envelope
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", result = null, failure = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buffer += decoder.decode(chunk.value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) >= 0) {
const raw = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
let event = "message", dataStr = "";
for (const line of raw.split("\n")) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!dataStr) continue;
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (event === "delta") onDelta && onDelta(data.text || "");
else if (event === "job") onJob && onJob(data);
else if (event === "done" || event === "pending") result = data;
else if (event === "error") failure = data;
}
}
if (failure) throw Object.assign(new Error(failure.message || "job failed"), { code: failure.code, job_id: failure.job_id });
return result;
}
let text = "";
const done = await runStream(INPUT, {
idempotencyKey: "llms-desk:create:sluicewren-demo:s1",
onDelta: (t) => { text += t; }, // servers and curl get deltas; a browser page may get none (see the note)
});
const full = (done && done.output && done.output.output) || text;
console.log(done && done.status, full.length, "characters"); // parse with parseReply() from step 5
// also import "bufio" and "strings"
// runStream posts to /run-stream and reads the SSE body the way sdk.js _readSse does.
// It returns the raw final payload (the "done" or "pending" event).
func runStream(input map[string]any, idemKey string, onDelta func(string)) (json.RawMessage, error) {
b, err := json.Marshal(input)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", api+"/run-stream", bytes.NewReader(b))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
if idemKey != "" {
req.Header.Set("Idempotency-Key", idemKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
var env struct {
Data json.RawMessage `json:"data"`
Error *APIError `json:"error"`
}
_ = json.NewDecoder(res.Body).Decode(&env)
if res.StatusCode < 200 || res.StatusCode > 299 {
if env.Error == nil {
env.Error = &APIError{Message: res.Status}
}
env.Error.Status = res.StatusCode
return nil, env.Error
}
return env.Data, nil // idempotent replay: a plain envelope
}
var result json.RawMessage
var failure *APIError
event, data := "message", ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 64*1024), 16*1024*1024) // a done payload carries the whole reply
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
case line == "" && data != "":
switch event {
case "delta":
var d struct {
Text string `json:"text"`
}
if json.Unmarshal([]byte(data), &d) == nil && onDelta != nil {
onDelta(d.Text)
}
case "done", "pending":
result = json.RawMessage(data)
case "error":
failure = &APIError{}
_ = json.Unmarshal([]byte(data), failure)
}
event, data = "message", ""
}
}
if err := sc.Err(); err != nil {
return nil, err
}
if failure != nil {
return nil, failure
}
return result, nil
}
// Usage:
// var sb strings.Builder
// raw, err := runStream(input, "llms-desk:create:sluicewren-demo:s1", func(t string) { sb.WriteString(t) })
// var done Job // Job from step 5
// _ = json.Unmarshal(raw, &done) // done.Output.Output is the reply string; parseReply() it
// POST /run-stream and read the SSE body line by line (the same events sdk.js _readSse reads).
// imports: java.net.http.HttpResponse, java.util.function.Consumer, java.util.stream.Stream
static JsonNode runStream(Object input, String idempotencyKey, Consumer<String> onDelta) throws Exception {
HttpResponse<Stream<String>> res = SkillSafe.HTTP.send(
SkillSafe.request("POST", "/run-stream", input, idempotencyKey).build(),
HttpResponse.BodyHandlers.ofLines());
String ctype = res.headers().firstValue("content-type").orElse("");
if (!ctype.contains("text/event-stream")) {
String text = String.join("\n", (Iterable<String>) res.body()::iterator);
return SkillSafe.unwrap(res.statusCode(), text); // idempotent replay, or throws on an error
}
JsonNode result = null, failure = null;
String event = "message";
StringBuilder data = new StringBuilder();
for (String line : (Iterable<String>) res.body()::iterator) {
if (line.startsWith("event:")) event = line.substring(6).trim();
else if (line.startsWith("data:")) data.append(line.substring(5).trim());
else if (line.isEmpty() && data.length() > 0) {
JsonNode payload;
try { payload = SkillSafe.JSON.readTree(data.toString()); } catch (Exception e) { payload = null; }
if (payload != null) {
switch (event) {
case "delta": if (onDelta != null) onDelta.accept(payload.path("text").asText("")); break;
case "done": case "pending": result = payload; break;
case "error": failure = payload; break;
default: break;
}
}
event = "message";
data.setLength(0);
}
}
if (failure != null) {
throw new SkillSafe.ApiException(0, failure.path("code").asText(null),
failure.path("message").asText("job failed"), null);
}
return result;
}
// in main(), with `input` from step 4:
StringBuilder text = new StringBuilder();
JsonNode done = runStream(input, "llms-desk:create:sluicewren-demo:s1", text::append);
String full = done != null && done.path("output").hasNonNull("output")
? done.path("output").path("output").asText() : text.toString();
System.out.println(parseReply(full).path("headline").asText()); // parseReply from step 5
# POST /run-stream and read the SSE body the way sdk.js _readSse does.
# Returns the final "done" (or "pending") payload; yields each delta's text to the block.
def run_stream(body:, idempotency_key: nil)
uri, req = build_request("POST", "/run-stream", body: body, idempotency_key: idempotency_key)
result = nil
failure = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
unless res["Content-Type"].to_s.include?("text/event-stream")
env = (JSON.parse(res.read_body.to_s) rescue {})
unless res.is_a?(Net::HTTPSuccess)
err = env["error"] || {}
raise ApiError.new(res.code.to_i, err["code"], err["message"] || res.message, err["details"])
end
return env["data"] # idempotent replay: a plain envelope
end
buffer = +""
res.read_body do |chunk|
buffer << chunk
while (idx = buffer.index("\n\n"))
raw = buffer.slice!(0, idx + 2)
event = "message"
data = +""
raw.split("\n").each do |line|
if line.start_with?("event:") then event = line[6..-1].strip
elsif line.start_with?("data:") then data << line[5..-1].strip
end
end
next if data.empty?
payload = (JSON.parse(data) rescue nil)
next if payload.nil?
case event
when "delta" then yield(payload["text"].to_s) if block_given?
when "done", "pending" then result = payload
when "error" then failure = payload
end
end
end
end
end
raise ApiError.new(nil, failure["code"], failure["message"] || "job failed", nil) if failure
result
end
text = +""
done = run_stream(body: INPUT, idempotency_key: "llms-desk:create:sluicewren-demo:s1") { |t| text << t }
full = done&.dig("output", "output") || text
puts parse_reply(full)["headline"] # parse_reply from step 5
<?php
// POST /run-stream and read the SSE body the way sdk.js _readSse does.
function runStream(array $body, ?string $idempotencyKey = null, ?callable $onDelta = null) {
global $TOKEN;
$buffer = ""; $all = ""; $result = null; $failure = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => headers($TOKEN, $idempotencyKey),
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$buffer, &$all, &$result, &$failure, $onDelta) {
$all .= $chunk;
$buffer .= $chunk;
while (($idx = strpos($buffer, "\n\n")) !== false) {
$raw = substr($buffer, 0, $idx);
$buffer = substr($buffer, $idx + 2);
$event = "message"; $data = "";
foreach (explode("\n", $raw) as $line) {
if (strpos($line, "event:") === 0) $event = trim(substr($line, 6));
elseif (strpos($line, "data:") === 0) $data .= trim(substr($line, 5));
}
if ($data === "") continue;
$payload = json_decode($data, true);
if (!is_array($payload)) continue;
if ($event === "delta" && $onDelta) $onDelta($payload["text"] ?? "");
elseif ($event === "done" || $event === "pending") $result = $payload;
elseif ($event === "error") $failure = $payload;
}
return strlen($chunk);
},
]);
if (curl_exec($ch) === false) throw new RuntimeException(curl_error($ch));
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$ctype = (string) curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
if (strpos($ctype, "text/event-stream") === false) return unwrap($status, $all); // replay or error
if ($failure) throw new ApiException(0, $failure["code"] ?? null, $failure["message"] ?? "job failed");
return $result;
}
$text = "";
$done = runStream($INPUT, "llms-desk:create:sluicewren-demo:s1", function ($t) use (&$text) { $text .= $t; });
$full = $done["output"]["output"] ?? $text;
echo parseReply($full)["headline"], "\n"; // parseReply from step 5
// POST /run-stream and read the SSE body line by line (the same events sdk.js _readSse reads).
static async Task<JsonNode?> RunStream(object input, string? idempotencyKey, Action<string>? onDelta)
{
using var req = SkillSafe.Request("POST", "/run-stream", input, idempotencyKey);
using var res = await SkillSafe.Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
var ctype = res.Content.Headers.ContentType?.MediaType ?? "";
if (ctype != "text/event-stream")
return SkillSafe.Unwrap((int)res.StatusCode, await res.Content.ReadAsStringAsync()); // replay or error
JsonNode? result = null, failure = null;
string evt = "message";
var data = new StringBuilder();
using var reader = new System.IO.StreamReader(await res.Content.ReadAsStreamAsync());
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event:")) evt = line.Substring(6).Trim();
else if (line.StartsWith("data:")) data.Append(line.Substring(5).Trim());
else if (line.Length == 0 && data.Length > 0)
{
JsonNode? payload = null;
try { payload = JsonNode.Parse(data.ToString()); } catch (JsonException) { }
if (payload != null)
{
if (evt == "delta") onDelta?.Invoke(payload["text"]?.GetValue<string>() ?? "");
else if (evt == "done" || evt == "pending") result = payload;
else if (evt == "error") failure = payload;
}
evt = "message";
data.Clear();
}
}
if (failure != null)
throw new SkillSafe.ApiException(0, failure["code"]?.GetValue<string>(),
failure["message"]?.GetValue<string>() ?? "job failed", null);
return result;
}
// with `input` from step 4:
var text = new StringBuilder();
var done = await RunStream(input, "llms-desk:create:sluicewren-demo:s1", t => text.Append(t));
var full = done?["output"]?["output"]?.GetValue<string>() ?? text.ToString();
Console.WriteLine(ParseReply(full)["headline"]); // ParseReply from step 5
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.