← Tour Desk
API
Tokens

Run Tour Desk from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: before any run, the page has read the code: it split the pasted files, numbered every line, mapped each file's symbols and landmarks (entry points, routes, auth checks, error paths, transactions, tests) with their real line numbers, and settled the reader (the persona) and the depth. In the revise task it has also checked the current tour against the files. It hands all of that to the run as facts. The run writes the tour or the revision; the page then holds the reply to the same anchor check its free board runs on any .tour file (tourreply.js), and adds $schema (https://aka.ms/codetour-schema) when it saves the file.

Base URL and the response shape

Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }

The token is minted for this app (the guest call takes {"slug":"tour-desk"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a key the run needs is missing. The page always sends all 10 of task, subject, ask, persona, ref, changed, files, facts, tour, feedback; in the write lane tour and feedback are plain sentences, and so are ref and changed when none was given.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes, chosen by task, in the order the work runs - write, then revise until the tour reads right:

A missing or unknown task is answered as write, and the reply says "task": "write". Both lanes send the same 10 fields. The facts are the page's reading - the files with their roles and languages, the directories, the symbols and landmarks with their real line numbers, the persona and its depth, and in the revise lane the page's check of the current tour - and a line starting "must:" is a rule the reply has to hold. The page's handoff puts the write reply's tour in tour for the revise lane; a .tour you paste goes there instead. The listing sent in files is capped at 45,000 characters: files that fit go whole, the rest are cut on symbol boundaries (and marked), and the symbol map in the facts still covers every file. The personas: new-joiner (9-13 steps), vibecoder (5-8 steps), architect (14-18 steps), pr-reviewer (7-11 steps), rca-investigator (7-11 steps), security-reviewer (7-11 steps), feature-explainer (7-11 steps), bug-fixer (7-11 steps).

The input

FieldRequiredWhat
taskyeswrite or revise. If it is missing or neither, do write and set "task": "write".
subjectyeswhat the tour is about, with the number of files and lines.
askyesthe request in the user's words: what the tour is for.
personayesthe reader and the depth, settled by the page, e.g. "new-joiner (9-13 steps)". Write for this reader.
refsend it (a sentence when none)the branch the reader will be on, or a sentence saying none was given.
changedsend it (a sentence when none)for a pull-request tour, the changed files (A added, M modified, D deleted, R renamed); otherwise a sentence saying there are none.
filesyesthe pasted files, each under a "=== path (N lines) ===" heading, every line prefixed with its line number and "| ". A line "[... lines A-B not sent ...]" marks code the page read but did not send; the symbol map in the facts still covers it.
factsyesthe page's reading: the files (path | lines | role | language | sent), the directories, the symbols (path:line kind name), the landmarks (path:line kind - code), the persona and its depth; in the revise task also the page's check of the current tour (step | rule | severity | evidence | the page's value). A line starting "must:" applies to your reply.
toursend it (a sentence in the write lane)revise task: the current tour as CodeTour JSON; otherwise a sentence saying there is none yet.
feedbacksend it (a sentence in the write lane)revise task: what the user wants changed, in their words; otherwise a sentence saying this is the first draft.
retry_notenooptional; if present, your previous reply was unusable - follow it.

The rules the model applies

From the source skill, @affaan-m/code-tour - the only standard the run applies:

House rules

The tour shape (both lanes)

tour is a CodeTour object:

Never add $schema, isPrimary or any other key: the page adds $schema itself.

The replies

write

Write the tour for the persona's reader, at the persona's depth, following the arc. Read the request for what the reader needs to come away with; the landmarks are where the path usually runs (entry, route, auth, error, transaction), not a checklist. For a root-cause request, walk the failure path from the trigger to the line that causes it, and make that line the gotcha. For a PR tour, cover the changed files first, new code before the wiring that calls it.

  1. summary - one sentence, at most 40 words: the path the tour walks and for whom, with the number of steps.
  2. focus - two to five lowercase words joined by hyphens naming the path, e.g. "payment-request-path"; the page saves the tour as .tours/<persona>-<focus>.tour.
  3. tour - the tour, in the tour shape above.
  4. beats - one entry per step, in step order, each one of: orientation, module-map, core-path, gotcha, closing.
  5. uncovered - at most 4 short sentences, each something the request asks about that the pasted files cannot show (code that is called but not pasted, a behaviour that lives elsewhere); [] when the files cover the request.
  6. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "write",
  "summary": "one sentence",
  "focus": "words-joined-by-hyphens",
  "tour": { "title": "short name", "description": "one sentence", "steps": [ { "directory": "src", "title": "short name", "description": "two to four sentences" }, { "file": "src/server.ts", "line": 12, "title": "short name", "description": "two to four sentences" }, { "title": "Next steps", "description": "what the reader can now do" } ] },
  "beats": [ "orientation", "core-path", "closing" ],
  "uncovered": [ ],
  "caveats": [ ]
}

The page holds it to its own reading (check() in tourreply.js; any warning makes the reply flagged): the reply's tour goes through the same check the free board runs (every path pasted, every line and selection in range, every pattern matching once, every directory real, the first step anchored, the depth the persona asks for, the ref, PR tours leading with the changed files, and each description talking about code that is at its anchor), plus what only a reply can get wrong: an anchor on a line the run was never sent, a beat list that does not match the steps, a link the request never gave, the request's plumbing in the prose.

revise

Rework the current tour on the user's feedback. Do what the feedback asks; fix every error and warning in the page's check of the current tour that the feedback does not ask you to keep - where the page's value gives the right line, the right path or the right ref, use it; keep every step the feedback and the check do not touch exactly as it was, word for word.

  1. summary - one sentence, at most 40 words: what changed and what the tour is now.
  2. focus - as in the write task (keep the current one unless the path changed).
  3. tour - the full revised tour, not only the changed steps, in the tour shape above.
  4. beats - as in the write task, for the revised steps.
  5. changes - one object per change: kind (one of added, removed, moved, re-anchored, rewritten, merged, split, retitled), steps (the step numbers involved, old to new: "3 -> 2" for a moved step, "- -> 4" for an added one, "5 -> -" for a removed one, "2, 3 -> 2" for a merge), what (one sentence: what changed), because (one sentence: the part of the feedback, or the page's finding, it answers). A step that only moves number because another was added or removed needs no change of its own. A change to the tour's ref is a change with steps "tour".
  6. uncovered - as in the write task.
  7. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "revise",
  "summary": "one sentence",
  "focus": "words-joined-by-hyphens",
  "tour": { "title": "short name", "description": "one sentence", "steps": [ { "file": "src/server.ts", "line": 12, "title": "short name", "description": "two to four sentences" } ] },
  "beats": [ "orientation" ],
  "changes": [ { "kind": "re-anchored", "steps": "3 -> 3", "what": "one sentence", "because": "one sentence" } ],
  "uncovered": [ ],
  "caveats": [ ]
}

The page requires everything the write lane requires of the revised tour, and then a change log reconciled with what actually changed between the two tours and with the page's flags on the current tour.

Worked examples

Three runs from the page's free samples (invented products): the write on "Onboard to a payments API", the revise of "Tour a PR (with a flawed draft)" - the teammate's draft fixed, and the write on "Why a job retried forever". Each input is exactly what the page sends; each reply is the saved reply the sample replays - but the files, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.

write - Onboard to a payments API

The page read 9 files, 406 lines and 39 symbols; the reader is new-joiner (9-13 steps), set by the sample. The reply's tour has 12 steps; its beats: orientation, module-map, module-map, core-path, core-path, core-path, core-path, core-path, core-path, gotcha, gotcha, closing.

Input (files is 14440 characters; the first 1398 are shown; facts is 4450 characters; the first 1598 are shown):

{
 "task": "write",
 "subject": "Tessavane payments - request path - 9 files, 406 lines",
 "ask": "Onboard a new backend engineer: how does a payment request get from the route to the ledger, and what do new people usually break?",
 "persona": "new-joiner (9-13 steps)",
 "ref": "main",
 "changed": "None given - this is not a pull-request tour.",
 "files": "=== package.json (22 lines) ===\n 1| {\n 2|   \"name\": \"tessavane-payments\",\n 3|   \"version\": \"0.7.2\",\n 4|   \"private\": true,\n 5|   \"description\": \"Tessavane payments API: card charges with idempotency and a double-entry ledger\",\n 6|   \"main\": \"dist/server.js\",\n 7|   \"scripts\": {\n 8|     \"build\": \"tsc -p .\",\n 9|     \"test\": \"vitest run\",\n10|     \"lint\": \"eslint src --ext .ts\"\n11|   },\n12|   \"dependencies\": {\n13|     \"express\": \"^4.19.2\"\n14|   },\n15|   \"devDependencies\": {\n16|     \"@types/express\": \"^4.17.21\",\n17|     \"@types/node\": \"^20.12.7\",\n18|     \"eslint\": \"^8.57.0\",\n19|     \"typescript\": \"^5.4.5\",\n20|     \"vitest\": \"^1.5.0\"\n21|   }\n22| }\n\n=== README.md (13 lines) ===\n 1| # Tessavane payments\n 2| \n 3| The service behind `POST /payments`. It authenticates a merchant API key,\n 4| claims an `Idempotency-Key`, charges the card through the processor client\n 5| and records the result in the double-entry ledger.\n 6| \n 7| - All amounts are integer minor units (`amount_minor: 1250` is 12.50).\n 8| - Every `POST /payments` must send an `Idempotency-Key` header.\n 9| - Dependencies (key store, processor client, databases) are injected into\n10|   `createServer`; nothing reads configuration from the environment.\n11| \n12| Run `npm test` before opening a PR. The ledger tests need the local\n13| Postgres from `docker compose up ledger-db`.\n\n=== src/server.ts (49 lines) ===\n 1| import express,\n\n[... 13042 more characters cut from this display ...]",
 "facts": "files: 9 files, 406 lines - 9 sent whole, 0 cut on symbol boundaries, 0 not sent (symbol map only)\nfile | lines | role | language | sent\npackage.json | 22 | config | JSON | whole\nREADME.md | 13 | docs | Markdown | whole\nsrc/server.ts | 49 | entry | TypeScript | whole\nsrc/routes/payments.ts | 86 | source | TypeScript | whole\nsrc/middleware/auth.ts | 38 | source | TypeScript | whole\nsrc/lib/idempotency.ts | 66 | source | TypeScript | whole\nsrc/services/charge.ts | 54 | source | TypeScript | whole\nsrc/services/ledger.ts | 38 | source | TypeScript | whole\nsrc/errors.ts | 40 | source | TypeScript | whole\ndirectories: src, src/lib, src/middleware, src/routes, src/services\nsymbols (path:line kind name):\nsrc/server.ts:6 type ServerConfig\nsrc/server.ts:12 type ServerDeps\nsrc/server.ts:16 function createServer\nsrc/server.ts:24 method paymentsRouter\nsrc/server.ts:36 function errorHandler\nsrc/server.ts:44 function start\nsrc/routes/payments.ts:8 type PaymentsDeps\nsrc/routes/payments.ts:15 type PaymentBody\nsrc/routes/payments.ts:21 function parsePaymentBody\nsrc/routes/payments.ts:39 function paymentsRouter\nsrc/middleware/auth.ts:4 type ApiKeyRecord\nsrc/middleware/auth.ts:9 type KeyStore\nsrc/middleware/auth.ts:14 type Request\nsrc/middleware/auth.ts:21 function requireApiKey\nsrc/lib/idempotency.ts:4 type IdempotencyState\nsrc/lib/idempotency.ts:6 type IdempotencyRecord\nsrc/lib/idempotency.ts:16 type IdempotencyStore\nsrc/lib/idempotency.ts:22 function hashRequest\nsrc/lib/idempotency.ts:26 type BeginResult\nsrc/lib/idempotency.ts:32 function beginIdempotent\nsrc/lib/idempotency.ts:58 function\n\n[... 2852 more characters cut from this display ...]",
 "tour": "No tour yet - this is the first draft.",
 "feedback": "This is the first draft."
}

Reply:

{
 "task": "write",
 "summary": "A 12-step new-joiner tour of POST /payments from server wiring through API-key auth, idempotency, the processor charge and the ledger transaction, ending on the failure modes new engineers most often break.",
 "focus": "payment-request-path",
 "tour": {
  "title": "Payments request path",
  "description": "After this tour the reader can trace a payment from the route to the ledger and change it without breaking idempotency or ledger balance.",
  "ref": "main",
  "steps": [
   {
    "directory": "src",
    "title": "Where the service lives",
    "description": "The service is small: the server entry, one route module under routes, the API-key middleware under middleware, idempotency under lib, and the charge and ledger services under services. A payment walks those folders in roughly that order. Every external system arrives as an injected dependency, so nothing here reads the environment."
   },
   {
    "file": "src/server.ts",
    "line": 16,
    "title": "createServer wires everything",
    "description": "`createServer` builds the Express app from a `ServerConfig` and a `ServerDeps` bag holding the key store, idempotency store, processor and ledger. Because dependencies are passed in, tests hand in fakes and production hands in real clients. A new dependency for the payments route must be threaded through both `ServerDeps` and the `paymentsRouter` call below."
   },
   {
    "file": "src/server.ts",
    "line": 22,
    "title": "Order decides what is authenticated",
    "description": "`app.use(requireApiKey(deps.keys))` guards only what is registered after it; `/healthz` sits above it and stays public. `paymentsRouter` is mounted next and `errorHandler` last. Gotcha: a new route added above the `requireApiKey` line silently ships without authentication, and middleware added after `errorHandler` never sees errors."
   },
   {
    "file": "src/middleware/auth.ts",
    "line": 28,
    "title": "Resolving the merchant",
    "description": "`authenticate` pulls the token with the `BEARER` regex, then calls `keys.lookup` and rejects a missing or `revoked` record with `UnauthorizedError`. On success it sets `req.merchantId`, which every later layer trusts as the tenant. Store failures go to `next(err)` and surface as a 500, not a 401."
   },
   {
    "file": "src/routes/payments.ts",
    "line": 42,
    "title": "The POST /payments handler",
    "description": "This handler is the whole request path. It first insists on the `idempotency-key` header and fails with `ValidationError` before touching the body, then reads `req.merchantId` set by the middleware. Everything after runs in order: parse, claim the key, charge, record, finish."
   },
   {
    "file": "src/routes/payments.ts",
    "line": 21,
    "title": "Validating the body",
    "description": "`parsePaymentBody` requires `amount_minor` to be a positive integer in minor units, so 12.50 must arrive as 1250. It checks `currency` against the supported list case-insensitively and returns it uppercased, and requires a non-empty `card_token`. Each failure is a `ValidationError`, which becomes a 400."
   },
   {
    "file": "src/lib/idempotency.ts",
    "line": 39,
    "title": "Claiming the key before money moves",
    "description": "`beginIdempotent` hashes the parsed body with `hashRequest` and calls `insertIfAbsent` with a `pending` row keyed by merchant and key. No existing row means a fresh request. An existing row with a different `requestHash` throws `IdempotencyMismatchError`, a `pending` one throws `InFlightError`, and a completed one is replayed with its stored status and body."
   },
   {
    "file": "src/services/charge.ts",
    "line": 43,
    "title": "Charging the card",
    "description": "`createCharge` calls `authorizeAndCapture` with a `reference` built from the merchant id and idempotency key, which the processor dedupes on, so a retry cannot capture twice. A non-approved result throws `CardDeclinedError`. The charge id comes from the injected `newId`, which the route passes as `randomUUID`."
   },
   {
    "file": "src/services/ledger.ts",
    "line": 34,
    "title": "One balanced ledger pair",
    "description": "`recordCharge` writes a debit to `clearingAccount` and a credit to `merchantAccount` inside one `db.transaction`, so both entries land or neither does. It re-checks that `amountMinor` is a positive integer. Any new ledger write belongs inside this transaction as a balanced pair, never as a lone `insertEntry`."
   },
   {
    "file": "src/routes/payments.ts",
    "line": 77,
    "title": "What happens when the ledger fails",
    "description": "Only `CardDeclinedError` is caught here and finished as a 402 with `finishIdempotent`. If `recordCharge` throws after the card was captured, `throw err` skips `finishIdempotent`, so the key stays `pending`. Gotcha: every retry then gets `InFlightError` while the money sits captured with no ledger entry, which needs reconciliation rather than a client retry."
   },
   {
    "file": "src/server.ts",
    "line": 37,
    "title": "Turning errors into responses",
    "description": "`errorHandler` maps any `HttpError` to its `status` and `code`, so the error classes alone decide 400, 401, 402, 409 and 422. Anything else is logged and returned as a generic 500. A new failure mode should get its own `HttpError` subclass rather than a plain `Error`."
   },
   {
    "title": "Next steps",
    "description": "The reader can now follow a payment from authentication to the ledger pair and knows the three easy breaks: routes above the auth line, ledger writes outside the transaction, and a ledger failure that strands a `pending` key. A good first change is adding a field to `PaymentBody` and threading it through `parsePaymentBody` and the hash."
   }
  ]
 },
 "beats": [
  "orientation",
  "module-map",
  "module-map",
  "core-path",
  "core-path",
  "core-path",
  "core-path",
  "core-path",
  "core-path",
  "gotcha",
  "gotcha",
  "closing"
 ],
 "uncovered": [
  "The real key store, idempotency store, processor client and ledger database implementations are injected and not shown.",
  "How a payment stuck in the pending state is reconciled lives outside this code."
 ],
 "caveats": [
  "No tests are included, so the tour cannot point at how fakes are wired into createServer."
 ]
}

What step 5 prints for it: 1 src/ Where the service lives | 2 src/server.ts:16 createServer wires everything | 3 src/server.ts:22 Order decides what is authenticated | 4 src/middleware/auth.ts:28 Resolving the merchant | 5 src/routes/payments.ts:42 The POST /payments handler | 6 src/routes/payments.ts:21 Validating the body | 7 src/lib/idempotency.ts:39 Claiming the key before money moves | 8 src/services/charge.ts:43 Charging the card | 9 src/services/ledger.ts:34 One balanced ledger pair | 10 src/routes/payments.ts:77 What happens when the ledger fails | 11 src/server.ts:37 Turning errors into responses | 12 content Next steps

The page's check: kept - nothing to flag

revise - Tour a PR (with a flawed draft)

The page read 5 files, 372 lines and 20 symbols; the reader is pr-reviewer (7-11 steps), set by the sample. The input carries the sample's own draft tour - a teammate's, pasted in the tour box - and the page's check of it (will not open cleanly: 3 errors, 2 warnings, 2 notes): tour schema (info); step 1 first-content (error); step 3 drift (warn); step 4 pattern-miss (error); step 7 line-range (error); tour closing (info); tour ref-base (warn). The revision has 8 steps.

Input (files is 10791 characters; the first 1396 are shown; facts is 3644 characters; the first 1579 are shown):

{
 "task": "revise",
 "subject": "Brightfold sync - the --dry-run PR - 5 files, 372 lines",
 "ask": "Tour this PR for a reviewer: the new dry-run flag and the planner behind it (pull request #218).",
 "persona": "pr-reviewer (7-11 steps)",
 "ref": "feature/dry-run",
 "changed": "M cmd/sync/main.go (modified)\nA internal/sync/plan.go (added)\nM internal/sync/apply.go (modified)\nA internal/sync/plan_test.go (added)",
 "files": "=== cmd/sync/main.go (73 lines) ===\n 1| // Command sync makes a destination directory match a source directory.\n 2| package main\n 3| \n 4| import (\n 5| \t\"context\"\n 6| \t\"flag\"\n 7| \t\"fmt\"\n 8| \t\"io\"\n 9| \t\"os\"\n10| \t\"os/signal\"\n11| \n12| \t\"git.example.com/brightfold/sync/internal/config\"\n13| \t\"git.example.com/brightfold/sync/internal/sync\"\n14| )\n15| \n16| func main() {\n17| \tos.Exit(run(os.Args[1:], os.Stdout, os.Stderr))\n18| }\n19| \n20| func run(args []string, stdout, stderr io.Writer) int {\n21| \tfs := flag.NewFlagSet(\"sync\", flag.ContinueOnError)\n22| \tfs.SetOutput(stderr)\n23| \tconfigPath := fs.String(\"config\", \"brightfold.conf\", \"path to the sync config file\")\n24| \tdryRun := fs.Bool(\"dry-run\", false, \"print the planned changes and exit without writing\")\n25| \tif err := fs.Parse(args); err != nil {\n26| \t\treturn 2\n27| \t}\n28| \n29| \tcfg, err := config.Load(*configPath)\n30| \tif err != nil {\n31| \t\tfmt.Fprintf(stderr, \"sync: %v\\n\", err)\n32| \t\treturn 1\n33| \t}\n34| \n35| \tctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)\n36| \tdefer stop()\n37| \n38| \tsrc, dst := sync.NewDirStore(cfg.Source), sync.NewDirStore(cfg.Destination)\n39| \tsrcEntries, err := src.List(ctx)\n40| \tif err != nil {\n41| \t\tfmt.Fprintf(stderr, \"sync: list source: %v\\n\", err)\n42| \t\treturn 1\n43| \t}\n44| \tdstEntries, err := dst.List(ctx)\n45| \tif err != nil {\n46| \t\tfmt.Fprintf(stderr, \"sync: list destination: %v\\n\",\n\n[... 9395 more characters cut from this display ...]",
 "facts": "files: 5 files, 372 lines - 5 sent whole, 0 cut on symbol boundaries, 0 not sent (symbol map only)\nfile | lines | role | language | sent\ncmd/sync/main.go | 73 | entry | Go | whole\ninternal/sync/plan.go | 99 | source | Go | whole\ninternal/sync/apply.go | 61 | source | Go | whole\ninternal/sync/plan_test.go | 69 | test | Go | whole\ninternal/config/config.go | 70 | config | Go | whole\ndirectories: cmd, cmd/sync, internal, internal/config, internal/sync\nsymbols (path:line kind name):\ncmd/sync/main.go:16 function main\ncmd/sync/main.go:20 function run\ncmd/sync/main.go:68 function printPlan\ninternal/sync/plan.go:11 type ActionKind\ninternal/sync/plan.go:19 method String\ninternal/sync/plan.go:32 type Entry\ninternal/sync/plan.go:39 type Action\ninternal/sync/plan.go:47 type Plan\ninternal/sync/plan.go:52 method Empty\ninternal/sync/plan.go:55 method Summary\ninternal/sync/plan.go:67 function BuildPlan\ninternal/sync/apply.go:11 type Store\ninternal/sync/apply.go:21 function Apply\ninternal/sync/apply.go:51 function applyOne\ninternal/sync/plan_test.go:9 function paths\ninternal/sync/plan_test.go:17 function TestBuildPlan\ninternal/sync/plan_test.go:60 function TestPlanSummaryAndEmpty\ninternal/config/config.go:14 type Config\ninternal/config/config.go:23 function Load\ninternal/config/config.go:63 method Excluded\nlandmarks (path:line kind - code):\ncmd/sync/main.go:16 entry - func main() {\ninternal/sync/apply.go:43 error - once.Do(func() { firstErr = fmt.Errorf(\"%s %s: %w\", a.Kind, a.Path, err); cancel() })\ninternal/sync/plan_test.go:17 test - func TestBuildPlan(t *testing.T) {\n\n[... 2065 more characters cut from this display ...]",
 "tour": "{\n  \"title\": \"Brightfold sync: the --dry-run PR\",\n  \"ref\": \"main\",\n  \"steps\": [\n    {\n      \"title\": \"What this PR does\",\n      \"description\": \"This PR adds `--dry-run` to `sync`. Instead of copying as it walks, the command now builds a plan first and only then applies it, so a dry run can print the plan and stop.\"\n    },\n    {\n      \"file\": \"internal/sync/plan.go\",\n      \"line\": 67,\n      \"title\": \"The new planner\",\n      \"description\": \"`BuildPlan` diffs the two listings into a `Plan`. Excluded paths are skipped via `Excluded`, and deletes are only planned when `DeleteExtra` is set.\"\n    },\n    {\n      \"file\": \"internal/sync/plan.go\",\n      \"line\": 14,\n      \"title\": \"Summarising a plan\",\n      \"description\": \"`Summary` counts actions by kind; both the dry-run output and the final `done:` line use it.\"\n    },\n    {\n      \"file\": \"internal/sync/apply.go\",\n      \"pattern\": \"^func Apply\\\\(ctx context\\\\.Context, cfg \\\\*config\\\\.Config, src, dst Store\\\\) error\",\n      \"title\": \"Apply now takes a plan\",\n      \"description\": \"`Apply` no longer reads the config or lists the stores itself. It executes the `Action` list from the plan with bounded concurrency.\"\n    },\n    {\n      \"file\": \"cmd/sync/main.go\",\n      \"line\": 24,\n      \"title\": \"The flag\",\n      \"description\": \"`dryRun` registers the `--dry-run` flag on the set built by `NewFlagSet`; it defaults to false.\"\n    },\n    {\n      \"file\": \"cmd/sync/main.go\",\n      \"line\": 68,\n      \"title\": \"Printing the plan\",\n      \"description\": \"`printPlan` writes one line per action and finishes with the `Summary` line; nothing is written to the destination.\"\n    },\n    {\n      \"file\": \"internal/sync/plan_test.go\",\n      \"line\": 84,\n      \"title\": \"Tests\",\n      \"description\": \"`TestPlanSummaryAndEmpty` checks that identical listings give an empty plan and the zero summary.\"\n    }\n  ]\n}",
 "feedback": "Fix what the page flagged, point ref at the PR branch, and lead with the new planner before the flag wiring."
}

Reply:

{
 "task": "revise",
 "summary": "Dropped the content-only opener, fixed the drifted, broken and out-of-range anchors, added the dry-run branch and a closing step, and pointed ref at feature/dry-run: an eight-step planner-first tour of the dry-run PR.",
 "focus": "dry-run-planner",
 "tour": {
  "title": "Brightfold sync: the --dry-run PR",
  "description": "Review the dry-run PR from the new planner through the applier to the flag that prints a plan without writing.",
  "ref": "feature/dry-run",
  "steps": [
   {
    "file": "internal/sync/plan.go",
    "line": 67,
    "title": "The new planner",
    "description": "`BuildPlan` diffs the two listings into a `Plan`. Excluded paths are skipped via `Excluded`, and deletes are only planned when `DeleteExtra` is set."
   },
   {
    "file": "internal/sync/plan.go",
    "line": 55,
    "title": "Summarising a plan",
    "description": "`Summary` counts actions by kind; both the dry-run output and the final `done:` line use it."
   },
   {
    "file": "internal/sync/apply.go",
    "selection": {
     "start": {
      "line": 21,
      "character": 1
     },
     "end": {
      "line": 49,
      "character": 2
     }
    },
    "title": "Apply now takes a plan",
    "description": "`Apply` walks `plan.Actions` and hands each `Action` to `applyOne`, reading bodies from `src` and writing to `dst`. The `slots` channel caps the work at `concurrency` goroutines, and a value below 1 is raised to 1. The first failure is wrapped with the action's `Kind` and `Path` and calls `cancel`, but actions already started still finish, so a reviewer should not expect an instant stop."
   },
   {
    "file": "cmd/sync/main.go",
    "line": 24,
    "title": "The flag",
    "description": "`dryRun` registers the `--dry-run` flag on the set built by `NewFlagSet`; it defaults to false."
   },
   {
    "file": "cmd/sync/main.go",
    "line": 51,
    "title": "Where the dry run stops",
    "description": "The plan is built with `BuildPlan` on every invocation, then `dryRun` is checked before anything else: `printPlan` runs and the command returns 0. The check comes before `plan.Empty()`, so a dry run on an in-sync tree prints only the zero summary line instead of `already in sync`, and `Apply` is never reached."
   },
   {
    "file": "cmd/sync/main.go",
    "line": 68,
    "title": "Printing the plan",
    "description": "`printPlan` writes one line per action and finishes with the `Summary` line; nothing is written to the destination."
   },
   {
    "file": "internal/sync/plan_test.go",
    "line": 60,
    "title": "Tests",
    "description": "`TestPlanSummaryAndEmpty` checks that identical listings give an empty plan and the zero summary."
   },
   {
    "title": "Next steps",
    "description": "The reviewer can now judge whether `BuildPlan` plans the right copy, update and delete actions and whether `--dry-run` really exits before any write. A good next check is running the tests in the sync package and trying a dry run against a config with `delete_extra` and an `exclude` glob."
   }
  ]
 },
 "beats": [
  "orientation",
  "core-path",
  "core-path",
  "core-path",
  "gotcha",
  "core-path",
  "core-path",
  "closing"
 ],
 "changes": [
  {
   "kind": "removed",
   "steps": "1 -> -",
   "what": "Removed the content-only opening step so the tour opens on the new planner.",
   "because": "The page flagged step 1 as content-only, and the feedback asks to lead with the new planner."
  },
  {
   "kind": "re-anchored",
   "steps": "3 -> 2",
   "what": "Pointed the summary step at the `Summary` method instead of the `ActionKind` constants.",
   "because": "The page found the step talks about `Summary` but pointed at the wrong line."
  },
  {
   "kind": "re-anchored",
   "steps": "4 -> 3",
   "what": "Replaced the pattern that matched nothing with a selection over the whole `Apply` function.",
   "because": "The page found the step's pattern matches nothing in the applier file."
  },
  {
   "kind": "rewritten",
   "steps": "4 -> 3",
   "what": "Rewrote the description to match the real `Apply` signature, its bounded concurrency and its first-error cancellation.",
   "because": "The old text described a signature taking the config, which the pattern miss showed the code does not have."
  },
  {
   "kind": "added",
   "steps": "- -> 5",
   "what": "Added a step on the `dryRun` branch, showing it returns before the empty check and before `Apply`.",
   "because": "The feedback asks for the planner before the flag wiring, and this is the wiring that makes the dry run stop."
  },
  {
   "kind": "re-anchored",
   "steps": "7 -> 7",
   "what": "Pointed the test step at the `TestPlanSummaryAndEmpty` definition rather than the last line of the file.",
   "because": "The page found the step's line is past the end of the test file; the test the step describes starts at its definition."
  },
  {
   "kind": "added",
   "steps": "- -> 8",
   "what": "Added a closing step saying what the reviewer can now check.",
   "because": "The page noted the tour ended on an anchored step without a next move."
  },
  {
   "kind": "rewritten",
   "steps": "tour",
   "what": "Changed ref from main to feature/dry-run and added a one-sentence tour description.",
   "because": "The feedback asks to point ref at the PR branch, and the page warned main looks like the base branch."
  }
 ],
 "uncovered": [
  "How `NewDirStore` lists and writes a directory is not in these files."
 ],
 "caveats": []
}

What step 5 prints for it: 1 internal/sync/plan.go:67 The new planner | 2 internal/sync/plan.go:55 Summarising a plan | 3 internal/sync/apply.go:21-49 Apply now takes a plan | 4 cmd/sync/main.go:24 The flag | 5 cmd/sync/main.go:51 Where the dry run stops | 6 cmd/sync/main.go:68 Printing the plan | 7 internal/sync/plan_test.go:60 Tests | 8 content Next steps

The page's check: kept - The log records 2 added steps; the page sees 0.

write - Why a job retried forever

The page read 6 files, 234 lines and 30 symbols; the reader is rca-investigator (7-11 steps), set by the sample. The reply's tour has 9 steps; its beats: orientation, core-path, core-path, core-path, module-map, core-path, core-path, gotcha, closing.

Input (files is 8899 characters; the first 1361 are shown; facts is 3317 characters; the first 1594 are shown):

{
 "task": "write",
 "subject": "Larkspur nightly export - the retry loop - 6 files, 234 lines",
 "ask": "Why did the nightly export job retry forever on Tuesday? Walk me down the failure path to the line that caused it.",
 "persona": "rca-investigator (7-11 steps)",
 "ref": "None given - leave ref out.",
 "changed": "None given - this is not a pull-request tour.",
 "files": "=== app/worker.py (57 lines) ===\n 1| \"\"\"Pulls jobs off the reports queue and runs them.\"\"\"\n 2| import logging\n 3| import time\n 4| from dataclasses import replace\n 5| \n 6| from app.config import WorkerConfig\n 7| from app.jobs.export import run_export\n 8| from app.queue import Job, JobQueue\n 9| from app.retry import RetryableError, backoff_seconds, should_retry\n10| \n11| log = logging.getLogger(\"larkspur.worker\")\n12| \n13| \n14| class Worker:\n15|     def __init__(self, queue: JobQueue, cfg: WorkerConfig, *, warehouse, storage):\n16|         self.queue = queue\n17|         self.cfg = cfg\n18|         self.handlers = {\n19|             \"nightly_export\": lambda payload, attempt: run_export(\n20|                 payload, attempt, warehouse=warehouse, storage=storage, cfg=cfg\n21|             ),\n22|         }\n23| \n24|     def process_one(self, job: Job) -> None:\n25|         handler = self.handlers.get(job.name)\n26|         if handler is None:\n27|             self.queue.dead_letter(job, reason=f\"no handler for {job.name}\")\n28|             return\n29|         try:\n30|             handler(job.payload, job.attempt)\n31|         except RetryableError as err:\n32|             self._retry_or_bury(job, err)\n33|             return\n34|         except Exception as err:  # a bug in the handler: do not retry\n35|             log.exception(\"job %s crashed\", job.job_id)\n36|\n\n[... 7538 more characters cut from this display ...]",
 "facts": "files: 6 files, 234 lines - 6 sent whole, 0 cut on symbol boundaries, 0 not sent (symbol map only)\nfile | lines | role | language | sent\napp/worker.py | 57 | source | Python | whole\napp/jobs/export.py | 59 | source | Python | whole\napp/retry.py | 23 | source | Python | whole\napp/queue.py | 44 | source | Python | whole\napp/config.py | 27 | config | Python | whole\ntests/test_retry.py | 24 | test | Python | whole\ndirectories: app, app/jobs, tests\nsymbols (path:line kind name):\napp/worker.py:14 class Worker\napp/worker.py:15 function __init__\napp/worker.py:24 function process_one\napp/worker.py:40 function _retry_or_bury\napp/worker.py:51 function run_forever\napp/jobs/export.py:9 class ExportError\napp/jobs/export.py:13 class WarehouseTimeout\napp/jobs/export.py:17 function export_day\napp/jobs/export.py:23 function build_csv\napp/jobs/export.py:32 function fetch_rows\napp/jobs/export.py:42 function upload\napp/jobs/export.py:51 function run_export\napp/retry.py:4 class RetryableError\napp/retry.py:11 function __init__\napp/retry.py:16 function should_retry\napp/retry.py:21 function backoff_seconds\napp/queue.py:8 class Job\napp/queue.py:15 class JobQueue\napp/queue.py:16 function __init__\napp/queue.py:21 function enqueue\napp/queue.py:24 function enqueue_later\napp/queue.py:34 function dequeue\napp/queue.py:40 function ack\napp/queue.py:43 function dead_letter\napp/config.py:10 class WorkerConfig\napp/config.py:19 function load_config\ntests/test_retry.py:6 function test_should_retry_allows_attempts_below_max\ntests/test_retry.py:11 function test_should_retry_stops_at_max\ntests/test_retry.py:17\n\n[... 1723 more characters cut from this display ...]",
 "tour": "No tour yet - this is the first draft.",
 "feedback": "This is the first draft."
}

Reply:

{
 "task": "write",
 "summary": "A 9-step root-cause tour for an rca-investigator, walking the nightly export from the worker loop through the retry decision to the upload-failure branch that drops the attempt count and retries forever.",
 "focus": "export-retry-loop",
 "tour": {
  "title": "Nightly export retry loop",
  "description": "Trace why a failed nightly export is retried forever and fix the line that resets its attempt count.",
  "steps": [
   {
    "directory": "app",
    "title": "Where the retry loop lives",
    "description": "The worker, the queue, the shared retry policy and the export job all sit under this package, with the export handler in its `jobs` subfolder. The failure path crosses all four, so the investigation starts at the worker loop and ends inside the export handler's error handling."
   },
   {
    "file": "app/worker.py",
    "line": 51,
    "title": "The loop that never stops pulling",
    "description": "`run_forever` dequeues a `job` and hands it to `process_one`, sleeping `poll_seconds` only when the queue is empty. Nothing here counts failures; whether a job comes back is decided entirely by what `process_one` does with the handler's exception, so a job that keeps getting re-queued keeps coming round."
   },
   {
    "file": "app/worker.py",
    "line": 31,
    "title": "Retryable errors take the retry branch",
    "description": "`process_one` calls the handler with `job.payload` and `job.attempt`. A `RetryableError` goes to `_retry_or_bury`, while any other `Exception` is dead-lettered as a handler bug. So an export can only loop if the export handler converts its failure into a retryable error, which it does for every export failure."
   },
   {
    "file": "app/worker.py",
    "selection": {
     "start": {
      "line": 40,
      "character": 1
     },
     "end": {
      "line": 49,
      "character": 1
     }
    },
    "title": "The attempt count comes from the error",
    "description": "`_retry_or_bury` computes `next_attempt` from `err.attempt`, not from `job.attempt`, then checks `should_retry` against `max_attempts`. On a retry it acks the job and re-enqueues `replace(job, attempt=next_attempt)`. The job's own counter is overwritten by whatever the error carried, so an error that reports the wrong attempt resets the job's history."
   },
   {
    "file": "app/retry.py",
    "line": 11,
    "title": "The silent default of zero",
    "description": "`RetryableError.__init__` stores `attempt` with a default of `0`, and the docstring says handlers must pass it through. An error raised without `attempt` therefore looks like a first failure, and the worker schedules attempt 1; `should_retry` returns `attempt < max_attempts`, so with the default limit of 5 that check always passes."
   },
   {
    "file": "app/jobs/export.py",
    "line": 47,
    "title": "A failing upload becomes an ExportError",
    "description": "`upload` writes the CSV under the `key` with `storage.put_object` and wraps any `OSError` in `ExportError`. A storage outage or permission problem that persists across attempts fails the same way every time, which is exactly the case where the retry cap has to work."
   },
   {
    "file": "app/jobs/export.py",
    "line": 57,
    "title": "The warehouse branch does it right",
    "description": "`run_export` catches `WarehouseTimeout` first and raises `RetryableError` with `attempt=attempt`, so warehouse timeouts climb toward the attempt limit and are eventually dead-lettered. `WarehouseTimeout` is a subclass of `ExportError`, which is why it must be caught before the broader handler below."
   },
   {
    "file": "app/jobs/export.py",
    "line": 59,
    "title": "Root cause: attempt is dropped",
    "description": "Every other `ExportError`, including the upload failure, raises `RetryableError` without `attempt`, so the worker always reads an attempt of 0. It then schedules attempt 1 forever: the retry check never fails and the backoff stays at its second step, about a minute, never growing toward the cap. The fix is passing `attempt=attempt` here as the branch above does."
   },
   {
    "title": "Next moves",
    "description": "Add the missing attempt argument to the generic export failure branch, then add a test that raises the upload failure repeatedly and expects the job to be dead-lettered after the configured maximum. The existing retry tests only build the error with an explicit attempt, so they could never catch this. Check the worker logs for the repeating attempt 1 warning to confirm Tuesday's loop was the upload path."
   }
  ]
 },
 "beats": [
  "orientation",
  "core-path",
  "core-path",
  "core-path",
  "module-map",
  "core-path",
  "core-path",
  "gotcha",
  "closing"
 ],
 "uncovered": [
  "The storage client behind `put_object` is not shown, so the specific error the upload hit on Tuesday cannot be read from this code.",
  "The deployed worker.toml values are not shown, so the actual `max_attempts` and backoff settings in production are unknown."
 ],
 "caveats": [
  "The tour assumes Tuesday's failures came through the generic export failure branch; a warehouse timeout alone would have been capped."
 ]
}

What step 5 prints for it: 1 app/ Where the retry loop lives | 2 app/worker.py:51 The loop that never stops pulling | 3 app/worker.py:31 Retryable errors take the retry branch | 4 app/worker.py:40-49 The attempt count comes from the error | 5 app/retry.py:11 The silent default of zero | 6 app/jobs/export.py:47 A failing upload becomes an ExportError | 7 app/jobs/export.py:57 The warehouse branch does it right | 8 app/jobs/export.py:59 Root cause: attempt is dropped | 9 content Next moves

The page's check: kept - nothing to flag

Build the input yourself

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

const K = TourKit, R = TourReply;
const f = K.desk({ name: "Hello service", ask: "Onboard a new engineer", persona: "", ref: "", changed: "", feedback: "",
  files: "==> src/server.ts <==\nimport { createApp } from \"./app\";\n\nconst port = 3000;\n\nexport function main() {\n  const app = createApp();\n  app.listen(port);\n}\n\nmain();\n",
  tour: JSON.stringify({ title: "Hello", steps: [ { title: "Intro", description: "hi" },
    { file: "src/server.ts", line: 40, title: "Main", description: "`main` starts it" } ] }) });
// f.files (path, n, role, lang, symbols, landmarks), f.dirs, f.persona (id, depth, why), f.check (findings, steps, by), f.verdict
// K.tourMd(t, f), K.stepsCsv(t, f), K.tourJson(t), K.board(f) - the free exports; K.tourPath(persona, focus) - the file name
const b = K.buildInput(f, "revise");                         // or "write" (needs only the files)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "revise");
// R.check(reply, b)                                         // {status: kept or flags, notes}

That example's tour opens on a content-only step and anchors a step past the end of its 10-line file, so the check names both errors (first-content, line-range) - fix the anchors free before you pay for a run. Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

One function that sends JSON and unwraps the response. Paste a personal token from https://tour-desk.skillsafe.ai/tokens.html, or leave it empty and mint a guest token in step 2.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"   # from https://tour-desk.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
  curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
    ${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}

Step 2 — a token

A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.

# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"tour-desk"}' | jq -r '.data.token')

Step 3 — GET /me

Who the token is (subject_type: user or guest) and the credit balance.

api GET /me | jq '.data | {subject_type, credits}' 

Step 4 — POST /estimate

Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.

# input.json: the run input object - {"task": "write", "subject": "Tessavane payments - request path - 9 files, 406 lines", "ask": "Onboard a new backend engineer: how does a payment request get from ...", "persona": "new-joiner (9-13 steps)", "ref": "main", "changed": "None given - this is not a pull-request tour.", "files": "=== package.json (22 lines) === ...", "facts": "files: 9 files, 406 lines - 9 sent whole, 0 cut on symbol boundaries, ...", "tour": "No tour yet - this is the first draft.", "feedback": "This is the first draft."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses tour-desk:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A write reply carries summary, focus, tour, beats, uncovered and caveats; a revise reply carries summary, focus, tour, beats, changes, uncovered and caveats. The code below prints every step of either tour: its number, its anchor (a directory, file:line, file:start-end for a selection, file /pattern/, a uri, or content) and its title.

KEY="tour-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r '.task, (.tour.steps | to_entries[] | "\(.key + 1) \(.value | if .directory then .directory + "/" elif .selection then "\(.file):\(.selection.start.line)-\(.selection.end.line)" elif .pattern then "\(.file) /\(.pattern)/" elif .line then "\(.file):\(.line)" else (.file // .uri // "content") end) \(.value.title)")' reply.json

Step 6 — POST /run-stream

The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.

# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: tour-desk:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json