← Sitemap Desk / API
Tokens

Drive Sitemap Desk from your own code

A plan, not a change. Nothing on your site is touched: the reply is a structure plan and a redirect map for you to review and apply yourself. Your URL list travels in inventory (up to 300 paths are sent; larger sites are sampled so every section is represented), so send only paths you are allowed to share.

Everything the web page does is available over HTTP. Send the page inventory of an existing site (root-relative paths, as they appear in its sitemap.xml, a crawl export or a URL list) with a little business context, and get the same architecture plan back: a status (sound, tidy_up, restructure), a headline and summary, findings with fixes, the proposed page hierarchy (with /* collection nodes for large sections), the 301 redirect map from old URLs to new ones (wildcard rules included), the header, CTA, footer and breadcrumb navigation, the internal-linking plan, next steps, and one answer per browser flag. The natural use is a migration or redesign routine: export the sitemap, run it through here, and review the redirect map before anything moves.

The model does not do the reading alone. Hosts and protocols that disagree, the same page under several spellings, a mixed trailing-slash policy, URL-convention problems, depth against the site type, folders without a hub page, synonym top-level sections, orphans and missing key pages are all worked out first by the page's free prescan in sitekit.js and sent as facts, a JSON string. See the facts string.

One lane: the task field

Every request names its lane in task. Sitemap Desk has exactly one.

taskwhat it doeswhat it needs
architectThe structure audit and architecture plan (site-architecture): site, status, headline, summary, findings, hierarchy, redirects, navigation, linking, next_steps and one prescan_responses entry per browser flag.site_name, inventory (the URL paths) and facts (the prescan, as a JSON string).

Any other value, or a missing task, is still answered as architect and the reply's lane is "architect". Always send "task": "architect"; the page's own guard refuses a body without a task.

Input fields

The body is one flat JSON object, built in the page by SiteKit.buildInput. Every value is a string. Required: task, site_name, inventory, facts.

fieldtyperequiredwhat it holds
taskstringyesAlways "architect".
site_namestringyesThe site's name; the page falls back to the host, then "Untitled site". Cut at 160 characters.
site_typestringnosaas, content, ecommerce, docs, hybrid or local (the page defaults to saas). Sets the typical maximum depth: saas 3, content 3, local 2, ecommerce 4, docs 4, hybrid 4.
goalsstringnoWhat the site is for (sign-ups, calls, sales). Cut at 2,000 characters.
audiencesstringnoWho it serves. Cut at 2,000 characters.
key_pagesstringnoThe paths that matter most, newline-separated ("/pricing\n/signup"); up to 20.
current_navstringnoThe current header and footer items, free text. Cut at 2,000 characters.
inventorystringyesThe site's URLs as root-relative paths, one per line. A path may be followed by a TAB and inlinks=N (internal links pointing at it, from a crawl) and/or a TAB and status=NNN (only when not 200): "/pricing\tinlinks=41\n/old-page\tinlinks=0\tstatus=404". At most 300 lines; for a larger site send a sample and say so in facts.clipped.
factsstringyesA JSON string (the output of JSON.stringify), never an object. In the page it is the browser's free prescan; its keys are listed below.
questionstringnoYour own question, answered inside summary. Cut at 4,000 characters; the page sends "" when empty.
retry_notestringnoOnly on a reformat retry, after a reply that could not be parsed: say what was wrong. Never on a first run.

The facts string

In the web page, facts is computed by the browser before you pay for anything: the free prescan reads your sitemap, crawl export or URL list, runs every structural check, raises the flags you see on the page, and serializes the result. An API caller has two options: build the same object yourself with the keys below (easiest by loading sitekit.js, which runs unchanged in Node, see building the body), or send a minimal one. The model parses it either way; a minimal facts simply means there are no browser flags to confirm or dismiss, so prescan_responses comes back empty and the structural checks rest on the model's own reading of inventory.

keywhat it holds
host, hostsThe main host ("" for a list of bare paths), and every host seen, most frequent first.
formatHow the inventory was read: urlset (sitemap.xml), index (a sitemap index), csv (crawl export), list or empty.
url_count, sent_count, clippedURLs found; URLs listed in inventory; and "", or a sentence saying the inventory is a sample of how many distinct URLs.
slash_policytrailing or none: the majority trailing-slash style.
max_depth, depth_counts, typical_max_depthThe deepest path; pages per depth ({"0":1,"1":7,"2":6}); the usual maximum for site_type.
sectionsUp to 40 top-level folders: path, pages, hub (whether the folder has its own page), children.
folders_without_hubFolders that hold pages but have no page of their own (up to 20).
has_inlinks, orphansWhether the export carried an Inlinks column, and up to 40 pages with 0 inlinks.
key_pagesEach key page: path, found, depth and, when known, inlinks.
expected_missingPages a site of this type usually has that were not found (/pricing, /contact...).
utility_urlsPresent only when found: up to 40 search, cart, account or login URLs, which are not content.
browser_statusThe prescan's status hint: restructure (any high flag), tidy_up (any medium), else sound.
flagsEvery flag: id (F1..), severity (high, medium, low), category (hosts, sitemap, status, duplicates, conventions, coverage, depth, hierarchy, linking, key_pages), message and up to five examples.

A minimal facts, before JSON.stringify, for a caller that runs no prescan:

{"host": "example.com", "format": "list", "url_count": 14, "sent_count": 14, "clipped": "",
 "slash_policy": "none", "typical_max_depth": 2, "browser_status": "sound", "flags": []}

Building the body

The surest way to match the page is to run the page's own engine. Load sitekit.js (it runs unchanged in Node via require()) and give analyze the same fields the page's form has; buildInput then samples the inventory, clips the text fields and serializes the facts:

set fieldwhat it holds
siteThe site's name.
typesaas, content, ecommerce, docs, hybrid or local.
goals, audiencesFree text.
key_pagesPaths, one per line (commas also work).
navThe current header and footer, free text.
inventoryThe raw export: a sitemap.xml, a crawl .csv (Address, Status Code and Inlinks columns are recognised) or one URL per line.
// make-body.js - build the run body with the SAME engine the web page uses.
// Save sitekit.js from https://sitemap-desk.skillsafe.ai/sitekit.js next to this file.
// Usage: node make-body.js sitemap.xml
const fs = require("fs");
const K = require("./sitekit.js");

const A = K.analyze({
  site: "Brindlecott Plumbing",
  type: "local",                  // saas, content, ecommerce, docs, hybrid or local
  goals: "Phone calls and booking requests from homeowners in the service area.",
  audiences: "Homeowners in Eugene and Springfield with an urgent or planned plumbing job.",
  key_pages: "/services\n/book",
  nav: "Header: Services, Service areas, About, Book a visit. Footer: Contact, Privacy.",
  inventory: fs.readFileSync(process.argv[2] || "sitemap.xml", "utf8")  // sitemap.xml, crawl CSV or URL list
});
const body = K.buildInput(A, { question: "" });   // a non-empty question is answered in summary
fs.writeFileSync("body.json", JSON.stringify(K.mustBeObject(body)));
console.log(A.flags.length, "flags; browser status", A.hint, "; sent", A.sent.paths.length, "of", A.sent.total);
console.log("Idempotency-Key: sitemap-desk:architect:" + K.hashInput(body) + ":a1");

Run on the Brindlecott sitemap from the page's examples, this produces the worked request below (its hash is 212ua9axq9u). mustBeObject is the page's own guard: it throws unless the body is a plain object. From another language, send the same field names and build facts with the keys above.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{"ok": true, "data": {"job_id": "job_...", "status": "queued"}}
{"ok": false, "error": {"code": "payment_required", "message": "..."}}

The token is minted for this app (the guest endpoint takes {"slug":"sitemap-desk"} in its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ….

The input object IS the request body. There is no {"input": …} wrapper. A wrapped body is answered with an unknown field 'input' warning, and the model never sees your inventory.

Error codes

statuscodewhat to do
400validation_errorA field is missing or the wrong type. Every field is a string: facts must be a JSON-encoded string, not an object.
401unauthorizedThe token is missing, malformed or expired. Get a new one from the token page.
402payment_requiredThe balance is below min_credits. Call /estimate first and top up.
403forbiddenThe token is valid but not for this app, or a guest token tried a metered run. A guest cannot run; sign in for a personal token.
404not_foundUnknown job id, or the app slug does not exist.
409conflictThe same Idempotency-Key was replayed with a different body. Change the key or send the original input.
429rate_limitedToo many requests. Back off and retry; do not tight-loop.
5xxinternalA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. A tiny client

One helper that sends the token, unwraps data and raises on ok: false. The token comes from the token page (Copy token or Copy shell export); step 2 covers the kinds of token and minting one from code.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="sitemap-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://sitemap-desk.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

2. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. A guest token, minted with POST /guest and {"slug":"sitemap-desk"}, can call /me and /estimate; the run is metered, so /run and /run-stream need a personal token.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://sitemap-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; a run needs a personal token from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" -d '{"slug":"sitemap-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}

3. Check the session and the balance

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run (free)

/estimate returns the model binding and the credits a run would reserve. It creates no job and charges nothing. Expect model_alias gpt-terra and markup_bps 1000 (a 10% markup). hold_credits is what the run reserves, not the price; min_credits is the least balance that can start it; the charged_credits reported after the run is usually far lower than the hold. The body is the input object itself, with no {"input": …} wrapper. /estimate does not validate the body, so check the shape yourself: an object whose every value is a string, task equal to architect, site_name, inventory and facts non-empty, and facts a JSON string that parses to an object.

# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js above, or by hand. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task")=="architect" and all(isinstance(v,str) for v in b.values()) and all(b.get(k,"").strip() for k in ("site_name","inventory","facts")) and isinstance(json.loads(b["facts"]),dict)'
INPUT=$(cat body.json)

call estimate "$INPUT"
# {"ok":true,"data":{"model":"...","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
#   "warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is usually far lower.

5. Run it, then poll

POST /run returns a job_id; poll GET /jobs/{id} until it is terminal. The reply is a string at data.output.output: JSON.parse it (step 7). Send an Idempotency-Key built from the lane, a hash of the input and the attempt number, sitemap-desk:architect:<hash>:a<attempt>, so a retried request returns the same job instead of billing a second run. Use one key per distinct input: a changed inventory or changed facts is a new hash, and replaying an old key with a different body is a 409. The page uses SiteKit.hashInput(body) for the hash (make-body.js prints that key); any stable digest of the body works from other languages. Leave retry_note out of the hash and bump the attempt instead.

# Always send an Idempotency-Key derived from the input. A retried request with
# the same key returns the SAME job instead of billing a second run.
KEY="sitemap-desk:architect:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"architect\",\"site\":{...},\"status\":\"sound\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json

6. Or stream it

POST /run-stream takes the same body and headers and answers with server-sent events: job (the job id), delta (chunks of the reply) and done (the status, charged_credits, truncated and, when present, the full output). A browser page may receive only tick heartbeats and then done, never a delta, so take the reply from done.output.output when it is there, fall back to the concatenated deltas, and fall back again to GET /jobs/{id}.

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag. Ignore `tick` heartbeats.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"lane\":\"architect\",\"site\":{\"name\":\"Brindlecott Plumbing\""}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

The reply is one JSON object, delivered as a string in data.output.output; you must JSON.parse it. The model is told to send no code fences, but tolerate them: strip a leading ```json and a trailing ```, keep everything from the first { to the last }, and parse that outer object.

# reply.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json, re
t = open("reply.json").read().strip()
t = re.sub(r"^```(?:json)?\s*", "", t, flags=re.I)
t = re.sub(r"\s*```\s*$", "", t)
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
print(r["status"], "-", r["headline"])
for f in r["findings"]:
    print(f["ref"] or "-", f["severity"], "|", f["finding"], "->", f["fix"])
for h in r["hierarchy"]:
    print("  " * h["level"] + h["path"], h["title"], "*new" if h["is_new"] else "", h["count"] or "")
for x in r["redirects"]:
    print("301", x["from"], "->", x["to"])
print([l["label"] for l in r["navigation"]["header"]], "CTA:", r["navigation"]["cta"]["label"])
EOF

Invariants worth asserting

The web page holds every reply to the browser's facts and to the inventory before it shows it (recon.js, reconcile()). Do the same before you hand a redirect map to a server:

# assert-reply.py - the core checks, for body.json and reply.json from the steps above.
import json, re
body = json.load(open("body.json")); facts = json.loads(body["facts"])
t = open("reply.json").read(); r = json.loads(t[t.index("{"):t.rindex("}") + 1])
S = lambda p: p if p == "/" else p.rstrip("/")
wild = lambda p: p.endswith("/*")
flags = {f["id"]: f for f in facts.get("flags", [])}
ids = [p["id"] for p in r["prescan_responses"]]
assert sorted(ids) == sorted(flags), "every flag answered exactly once"
dismissed = {p["id"] for p in r["prescan_responses"] if p["status"] == "dismissed"}
standing = [f for i, f in flags.items() if i not in dismissed]
rank = ["sound", "tidy_up", "restructure"]
floor = 2 if any(f["severity"] == "high" for f in standing) else 1 if any(f["severity"] == "medium" for f in standing) else 0
assert rank.index(r["status"]) >= floor, "status looser than the flags left standing"
covered = {x for f in r["findings"] for x in re.findall(r"F\d+", f["ref"])}
assert all(f["id"] in covered for f in standing if f["severity"] != "low")
H = r["hierarchy"]; plan = {S(h["path"]) for h in H}
assert "/" in plan, "no homepage node"
for h in H:
    if h["path"] == "/": continue
    par = S(h["parent"] or "/")
    assert par in plan, h["path"] + " has a parent outside the plan"
    assert par == "/" or (S(h["path"]) + "/").startswith(par.removesuffix("/*") + "/"), h["path"] + " does not mirror its parent"
    assert not re.search(r"[A-Z_?]|\.(html?|php|aspx?)$", h["path"]), h["path"] + " breaks the URL rules"
inv = [l.split("\t")[0] for l in body["inventory"].splitlines() if l.strip()]
under = lambda p, w: (p + "/").startswith(w.removesuffix("/*") + "/") and p != w.removesuffix("/*")
in_plan = lambda p: S(p.removesuffix("/*") if wild(p) else p) in plan or any(wild(w) and under(p, w) for w in plan)
R = r["redirects"]
for x in R:
    assert (any(under(p, x["from"]) for p in inv) if wild(x["from"]) else x["from"] in inv), "redirect from outside the inventory: " + x["from"]
    assert in_plan(x["to"]), "redirect to a page not in the plan: " + x["to"]
    assert x["from"] != x["to"], "self-redirect"
froms = [x["from"] for x in R]
assert len(froms) == len(set(froms)), "a URL is redirected twice"
assert not any(x["to"] in froms for x in R if not wild(x["to"])), "redirect chain"
nav = r["navigation"]
for l in nav["header"] + [nav["cta"]] + [l for g in nav["footer"] for l in g["links"]]:
    assert not l.get("path") or S(l["path"]) in plan, "nav link outside the plan: " + l["path"]
skip = set(facts.get("utility_urls", [])) | {l.split("\t")[0] for l in body["inventory"].splitlines() if "\tstatus=" in l}
lost = [p for p in inv if p not in skip and not in_plan(p.split("?")[0]) and not any(p == x["from"] or (wild(x["from"]) and under(p, x["from"])) for x in R)]
assert not lost, "inventory URLs neither kept nor redirected: " + ", ".join(lost[:5])

The output contract

Every key is always present. Arrays may be empty and strings may be "" when there is nothing to say. An enum is written "a|b|c": the reply carries exactly one of the values. Text fields are plain prose: no Markdown.

{"lane":"architect",
 "site":{"name":"...","type":"saas|content|ecommerce|docs|hybrid|local"},
 "status":"sound|tidy_up|restructure",
 "headline":"...","summary":"...",
 "findings":[{"ref":"F1, F4","severity":"high|medium|low","finding":"...","fix":"..."}],
 "hierarchy":[
  {"path":"/","title":"Homepage","parent":"","level":0,"nav":"none","priority":"high","is_new":false,"count":null},
  {"path":"/features","title":"Features","parent":"/","level":1,"nav":"header|header_dropdown|footer|sidebar|none","priority":"high|medium|low","is_new":true,"count":null},
  {"path":"/blog/*","title":"Blog posts","parent":"/blog","level":2,"nav":"none","priority":"medium","is_new":false,"count":212}],
 "redirects":[
  {"from":"/Product/Analytics","to":"/features/analytics","reason":"..."},
  {"from":"/resources/*","to":"/library/*","reason":"the whole folder moves, rest of the path kept"}],
 "navigation":{"header":[{"label":"Features","path":"/features"}],
  "cta":{"label":"Start free trial","path":"/signup"},
  "footer":[{"group":"Company","links":[{"label":"About","path":"/about"}]}],
  "breadcrumbs":"..."},
 "linking":{"hubs":[{"hub":"/features","spokes":["/features/analytics"]}],
  "cross_links":[{"from":"/features/analytics","to":"/customers/acme","anchor":"..."}],
  "orphans":[{"path":"/old-page","link_from":["/blog"]}]},
 "next_steps":["..."],
 "prescan_responses":[{"id":"F1","status":"confirmed|dismissed","reason":"..."}]}
keyshapewhat it holds
lanestringAlways "architect".
site{name, type}The site's name and its type, one of the six site_type values.
statusenumThe state of the structure; see the next table.
headlinestringOne sentence: the state of the structure and the single most important change.
summarystring3-5 sentences: what is wrong, what the plan does, what it costs to migrate; answers question when there is one.
findingsarray of {ref, severity, finding, fix}At most 12. One per confirmed high or medium flag (several ids may share one, "F3, F5"), plus any the model found itself in the input (ref "").
hierarchyarray of {path, title, parent, level, nav, priority, is_new, count}At most 60 nodes: the homepage, every section hub and every page at level 1 or 2. level is steps below the homepage; parent is "" for /; is_new marks a proposed page. A section of many similar detail pages is its hub, up to 3 representative children, and ONE collection node whose path ends in /* ("/blog/*"): every page under that folder keeps its pattern. count is a number only on /* nodes, otherwise null.
redirectsarray of {from, to, reason}At most 80 permanent (301) redirects. from is copied exactly from the inventory (same case, trailing slash and query string); to is a node in the plan. A wildcard rule moves a whole folder: "from": "/resources/*", "to": "/library/*" keeps the rest of the path, "to": "/library" sends the whole folder to one page. Host, protocol and trailing-slash-policy fixes are one server rule each and appear in next_steps, not here. Empty when nothing moves.
navigationobjectheader ({label, path}, 4-7 items), cta ({label, path}, rightmost and separate), footer (columns of {group, links}) and breadcrumbs (a sentence or two). Every path is a node in hierarchy.
linkingobjecthubs ({hub, spokes}; spokes link back to the hub), cross_links ({from, to, anchor} with descriptive anchor text), orphans ({path, link_from}, one per facts.orphans entry that stays).
next_stepsarray of strings3-8 ordered, concrete migration steps.
prescan_responsesarray of {id, status, reason}Exactly one per flag in facts.flags: confirmed or dismissed, with the reason. Empty when no flags were sent.

Status

statusmeaning
soundNo confirmed high or medium flags; the plan only suggests additions (navigation, links, new pages).
tidy_upThe hierarchy is basically right, but conventions, hubs, duplicates or links need fixing (confirmed medium flags).
restructureAny confirmed high flag, or the hierarchy itself must change: sections merged, moved or renamed, many URLs changing.

The model may be stricter than facts.browser_status, never looser, unless it dismissed the flags that set it.

Enums

wherevaluesthe page's fallback
statussound, tidy_up, restructurerestructure
findings[].severityhigh, medium, lowmedium
hierarchy[].navheader, header_dropdown, footer, sidebar, nonenone
hierarchy[].priorityhigh, medium, lowmedium
prescan_responses[].statusconfirmed, dismissedconfirmed

Worked example

The Brindlecott Plumbing sitemap from the page's examples: a local business with 14 URLs, two folders (/services and /service-areas) that both have hub pages, one consistent no-trailing-slash style, and both key pages one click from the homepage. The prescan raised no flags, so browser_status is sound and prescan_responses comes back empty. This request is complete and sendable as it stands; its Idempotency-Key from the page is sitemap-desk:architect:212ua9axq9u:a1.

The request body:

{
 "task": "architect",
 "site_name": "Brindlecott Plumbing",
 "site_type": "local",
 "goals": "Phone calls and booking requests from homeowners in the service area.",
 "audiences": "Homeowners in Eugene and Springfield with an urgent or planned plumbing job.",
 "key_pages": "/services\n/book",
 "current_nav": "Header: Services, Service areas, About, Book a visit. Footer: Contact, Privacy.",
 "inventory": "/\n/services\n/services/drain-cleaning\n/services/water-heaters\n/services/leak-repair\n/services/emergency-plumbing\n/service-areas\n/service-areas/eugene\n/service-areas/springfield\n/about\n/reviews\n/book\n/contact\n/privacy",
 "facts": "{\"host\":\"brindlecottplumbing.example\",\"hosts\":[\"brindlecottplumbing.example\"],\"format\":\"urlset\",\"url_count\":14,\"sent_count\":14,\"clipped\":\"\",\"slash_policy\":\"none\",\"max_depth\":2,\"depth_counts\":{\"0\":1,\"1\":7,\"2\":6},\"typical_max_depth\":2,\"sections\":[{\"path\":\"/services\",\"pages\":5,\"hub\":true,\"children\":4},{\"path\":\"/service-areas\",\"pages\":3,\"hub\":true,\"children\":2},{\"path\":\"/about\",\"pages\":1,\"hub\":true,\"children\":0},{\"path\":\"/reviews\",\"pages\":1,\"hub\":true,\"children\":0},{\"path\":\"/book\",\"pages\":1,\"hub\":true,\"children\":0},{\"path\":\"/contact\",\"pages\":1,\"hub\":true,\"children\":0},{\"path\":\"/privacy\",\"pages\":1,\"hub\":true,\"children\":0}],\"folders_without_hub\":[],\"has_inlinks\":false,\"orphans\":[],\"key_pages\":[{\"path\":\"/services\",\"found\":true,\"depth\":1},{\"path\":\"/book\",\"found\":true,\"depth\":1}],\"expected_missing\":[],\"browser_status\":\"sound\",\"flags\":[]}",
 "question": ""
}

Its facts, decoded:

{
 "host": "brindlecottplumbing.example",
 "hosts": [
  "brindlecottplumbing.example"
 ],
 "format": "urlset",
 "url_count": 14,
 "sent_count": 14,
 "clipped": "",
 "slash_policy": "none",
 "max_depth": 2,
 "depth_counts": {
  "0": 1,
  "1": 7,
  "2": 6
 },
 "typical_max_depth": 2,
 "sections": [
  {
   "path": "/services",
   "pages": 5,
   "hub": true,
   "children": 4
  },
  {
   "path": "/service-areas",
   "pages": 3,
   "hub": true,
   "children": 2
  },
  {
   "path": "/about",
   "pages": 1,
   "hub": true,
   "children": 0
  },
  {
   "path": "/reviews",
   "pages": 1,
   "hub": true,
   "children": 0
  },
  {
   "path": "/book",
   "pages": 1,
   "hub": true,
   "children": 0
  },
  {
   "path": "/contact",
   "pages": 1,
   "hub": true,
   "children": 0
  },
  {
   "path": "/privacy",
   "pages": 1,
   "hub": true,
   "children": 0
  }
 ],
 "folders_without_hub": [],
 "has_inlinks": false,
 "orphans": [],
 "key_pages": [
  {
   "path": "/services",
   "found": true,
   "depth": 1
  },
  {
   "path": "/book",
   "found": true,
   "depth": 1
  }
 ],
 "expected_missing": [],
 "browser_status": "sound",
 "flags": []
}

The reply, parsed from data.output.output and abbreviated: 2 of 4 findings, 6 of 14 hierarchy nodes, 1 of 3 footer columns, 1 of 2 hubs, 2 of 6 cross links and 2 of 6 next steps are shown, and the summary ends in …. The status is sound: nothing moves, so redirects is empty, and the plan is navigation and linking work only. A site that must restructure gets the same keys filled with redirects (exact and wildcard) and, for large sections, /* collection nodes, as in the contract above.

{
 "lane": "architect",
 "site": {
  "name": "Brindlecott Plumbing",
  "type": "local"
 },
 "status": "sound",
 "headline": "The 14-page structure is already flat, consistent and within two levels, so no URL should change; the most valuable move is to put Book a visit in a separate rightmost header CTA and cross-link every service page with the Eugene and Springfield area pages.",
 "summary": "The site is in good shape: every page sits at depth 1 or 2, both folders (/services and /service-areas) have hub pages, all paths are lowercase, hyphenated and follow one no-trailing-slash style, and both key pages (/services and …",
 "findings": [
  {
   "ref": "",
   "severity": "low",
   "finding": "The current header lists Book a visit alongside ordinary sections, and Reviews and Contact are missing from it even though phone calls and bookings are the site's goals and reviews are the main trust signal for a local trade.",
   "fix": "Header: Services, Service areas, Reviews, About, Contact, with Book a visit as a separate button at the far right."
  },
  {
   "ref": "",
   "severity": "low",
   "finding": "The footer holds only Contact and Privacy, so the service and area pages that a homeowner scans for at the bottom of a page are not reachable from it.",
   "fix": "Group the footer into Services, Service areas and Company columns, linking all four service pages, both area pages, and About, Reviews, Contact and Privacy."
  }
 ],
 "hierarchy": [
  {
   "path": "/",
   "title": "Homepage",
   "parent": "",
   "level": 0,
   "nav": "none",
   "priority": "high",
   "is_new": false,
   "count": null
  },
  {
   "path": "/services",
   "title": "Services",
   "parent": "/",
   "level": 1,
   "nav": "header",
   "priority": "high",
   "is_new": false,
   "count": null
  },
  {
   "path": "/services/emergency-plumbing",
   "title": "Emergency plumbing",
   "parent": "/services",
   "level": 2,
   "nav": "footer",
   "priority": "high",
   "is_new": false,
   "count": null
  },
  {
   "path": "/service-areas",
   "title": "Service areas",
   "parent": "/",
   "level": 1,
   "nav": "header",
   "priority": "high",
   "is_new": false,
   "count": null
  },
  {
   "path": "/service-areas/eugene",
   "title": "Eugene",
   "parent": "/service-areas",
   "level": 2,
   "nav": "footer",
   "priority": "high",
   "is_new": false,
   "count": null
  },
  {
   "path": "/book",
   "title": "Book a visit",
   "parent": "/",
   "level": 1,
   "nav": "header",
   "priority": "high",
   "is_new": false,
   "count": null
  }
 ],
 "redirects": [],
 "navigation": {
  "header": [
   {
    "label": "Services",
    "path": "/services"
   },
   {
    "label": "Service areas",
    "path": "/service-areas"
   },
   {
    "label": "Reviews",
    "path": "/reviews"
   },
   {
    "label": "About",
    "path": "/about"
   },
   {
    "label": "Contact",
    "path": "/contact"
   }
  ],
  "cta": {
   "label": "Book a visit",
   "path": "/book"
  },
  "footer": [
   {
    "group": "Service areas",
    "links": [
     {
      "label": "Eugene",
      "path": "/service-areas/eugene"
     },
     {
      "label": "Springfield",
      "path": "/service-areas/springfield"
     }
    ]
   }
  ],
  "breadcrumbs": "Show breadcrumbs on level-2 pages only, mirroring the URL, for example Home > Services > Water heaters and Home > Service areas > Eugene; level-1 pages do not need them."
 },
 "linking": {
  "hubs": [
   {
    "hub": "/service-areas",
    "spokes": [
     "/service-areas/eugene",
     "/service-areas/springfield"
    ]
   }
  ],
  "cross_links": [
   {
    "from": "/service-areas/eugene",
    "to": "/services/emergency-plumbing",
    "anchor": "emergency plumbing in Eugene"
   },
   {
    "from": "/service-areas/springfield",
    "to": "/services/water-heaters",
    "anchor": "water heater repair and replacement in Springfield"
   }
  ],
  "orphans": []
 },
 "next_steps": [
  "Keep all 14 URLs unchanged; no redirects are needed and the no-trailing-slash style is already consistent.",
  "Rebuild the header as Services, Service areas, Reviews, About, Contact, with Book a visit as a separate rightmost button on every page."
 ],
 "prescan_responses": []
}

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and reports truncated: true, in the done event of /run-stream and on the job from GET /jobs/{id}. What you hold is then a prefix of the reply. The web page closes the cut-off JSON (Recon.closeJson in recon.js) and shows the sections that arrived, out of eleven: site, status, headline, summary, findings, hierarchy, redirects, navigation, linking, next_steps and prescan_responses. A truncated plan can be missing redirects or whole branches of the hierarchy, so never deploy a redirect map from one: check the flag, top up, and resubmit with the attempt suffix on the Idempotency-Key incremented (sitemap-desk:architect:<hash>:a2).

If a complete reply will not parse as one JSON object, the page retries once, as the next attempt, with a retry_note saying what was wrong and asking for only the JSON object for task architect. Do the same: keep the hash, bump the attempt, add retry_note.