← Routefence / API
Tokens

Drive Routefence from your own code

The page reads the files behind one Next.js App Router route in the browser for free — one or more .tsx, .ts, .jsx or .js files joined into one paste, each introduced by a // file: <path> line, plus package.json for the Next.js and React versions — with its line numbers. It lexes strings, template literals, regex literals and comments out first (so a string that mentions useState( is never a finding), finds every function, component, export, import and JSX tag, resolves the imports between the pasted files (relative paths and the @/ alias) and follows 'use client' through them, so it knows which files render on the server, which are bundled for the browser, and which are both. It inventories every hook, every prop passed into a Client Component and whether it is a value, a function or a Server Action, every read of params, searchParams, cookies() and headers() and whether it is awaited, every Server Action and whether it writes, checks the caller and revalidates, and every route segment config value. Then it runs 17 deterministic checks drawn from App Router practice (catalogue ids NX-01..NX-17, plus NX-18 for context): hooks, DOM event handlers or browser APIs in a Server Component, an error.tsx that is not a Client Component, a directive that is not the first statement, server-only modules imported into a Client Component, async Client Components, functions passed as props from the server to the client, params/searchParams and cookies()/headers() read without await on Next.js 15+, Pages Router APIs inside app/, metadata exports Next.js rejects or ignores, Server Actions that write with no authorization check or never revalidate, redirect() swallowed by a try/catch, segment config that is not a static literal, a route.ts and a page.tsx in one segment, credential literals, and work moved to the client that belongs on the server. Nothing is built or run, and it is not next build or next lint: every flag is a reading of the text. Its two paid steps are available over HTTP. You send one JSON object with task set to "audit" or "fix", the paste as code and the page’s check as a facts string, and the model replies with one JSON object: an audit that rates the four areas, answers every flag and gives exact before/after edits per finding, or the whole fixed file(s) with a list of every change tied to the page’s flags.

Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends Content-Type: application/json and, once you have a token, Authorization: Bearer <token>. The paths below (/guest, /me, /estimate, /run, /jobs/{job_id}, /run-stream) are relative to it, exactly as sdk.js calls them. A plain-text summary of the app for agents is at /llms.txt. Both lanes run on the gpt-terra model with a 10% publisher markup. A run needs a signed-in (user) token and is metered: the platform places a hold on your credits when the run starts and then charges only what the run used. /estimate is free.

The response envelope

Responses are JSON of the form {"ok": ..., "data": ..., "error": {...}}. The page’s SDK (sdk.js) decides success from the HTTP status, not from ok: on a 2xx it returns data; otherwise it raises an error whose message is error.message (or the HTTP status text when the body carries none), with status, code (error.code) and details (error.details) attached. A body that is not JSON is treated as {}.

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

Error codes

Branch on the HTTP status and error.code, show error.message. The page handles 401 and 402 by name; the rest reach it as a generic failure (“The run failed: ...”).

HTTPerror.codeWhat it means / what to do
400VALIDATION_ERRORThe body is not a JSON object, a required field (task, code, facts) is missing, or a declared field has the wrong type. Every field is a string; sending facts or audit_handoff as an object is the usual cause.
401UNAUTHORIZEDNo token, or one that expired or was revoked. The page forgets the signed-in user and says “Your session expired - sign in again.” Get a new token.
402INSUFFICIENT_CREDITSThe balance cannot cover the run. The page says “Not enough credits for this run.” with a top-up link, and tries never to submit into a 402: it disables the run button when the /me balance is below the estimate’s min_credits.
404NOT_FOUNDA wrong path, or a job id that does not belong to this token.
409CONFLICTAn Idempotency-Key replayed with a different body. Send the original body, or use a new key (the page bumps its :a<attempt> suffix for a retry).
429RATE_LIMITEDToo many requests. Back off with a delay; do not tight-loop, and never resend a metered run without its original Idempotency-Key.

Failures that do not arrive as an HTTP status:

WhatWhat happens
event: error on /run-streamThe SDK raises an error carrying the event’s message, code and job_id.
Job status: "failed"Polling stops; the job’s error field says why.
Polling past 180 sThe SDK’s own client-side limit (waitForJob, 1 s interval, 180 000 ms default): it throws “job timed out”. The job itself is not cancelled.

The task field: two lanes

task routes the run, and it takes exactly two values: "audit" and "fix". Each lane has its own reply contract, and a reply never mixes them. The skill’s instructions tell the model to do the audit lane when task is missing or anything else (and the page’s own Nextkit.buildInput turns any lane that is not "fix" into "audit"), so a typo silently gets you an audit: always send one of the two values.

taskWhat it doesverdictUse it when
"audit"Reviews the route the way a senior Next.js engineer reviews an App Router segment before it goes to production: says what the route renders and what runs on the server and in the browser, rates the four areas (boundaries, data, mutations, conventions) as sound, fragile or broken, confirms or dismisses every page flag, adds what text rules cannot see, and gives the verbatim evidence and exact before/after edits for each finding, listed in the order to apply them — what stops the build or the render first (hooks and handlers on the server, function props, async Client Components, server-only imports, error boundaries, Pages Router APIs, metadata, route conflicts), then request APIs for this version, then Server Action security and swallowed redirects, then caching, revalidation and segment config, then hygiene — with a plan that ends in next build, next lint and a click-through of the route in next dev with the browser console open.blocked | needs_work | readyBefore shipping: does the route build and render, does a function or a database client cross into the browser, are params and cookies() read the way this Next.js version requires, can anyone call its Server Actions, and what should change first.
"fix"Writes the fixed version of the pasted code: the whole of every file it changes (and at most two new files next to them, such as a leaf Client Component split out of a Server Component, never a new page, route or layout), with the interactive part moved into a Client Component, handlers kept inside the Client Component instead of passed down, server-only code kept on the server, every params and cookies() awaited on Next.js 15+, Server Actions that check the caller and revalidate, and redirects moved out of swallowing try/catch blocks — plus a changes list tied to the page flags, the verify steps (next build, next lint and the check that would have caught the worst problem), and what it left open. No URL moves and no export renamed unless the note asks for it.fixed | partialWhen you want the code changed, not a list of findings: a route you already know is broken, or what is left after an audit.

The page chains the two: after an audit, “Apply every edit and fix the rest” puts every audit edit into the paste, switches to the fix lane on that edited paste and adds an audit_handoff string (below) with the audit’s verdict, headline and findings. Over the API that is a second run with task: "fix", the edited paste as code, facts recomputed from the edited paste, and that string. The page drops the handoff as soon as the paste is edited again.

Guards the page applies before a run and the API does not: it refuses to price or run a paste it cannot read (“Nothing was pasted.”, “No TypeScript or JavaScript file was found. Paste the route's .tsx/.ts files, with a // file: app/.../page.tsx line above each file.”, or “This does not read as TypeScript or JavaScript source: no import, export or function was found.”), and it warns before a run on a paste that may not be App Router code (“No next/ import and no app/ path was found: this may not be App Router code. Name each file with its path (// file: app/.../page.tsx) so the page can tell what it is.”) or has no function at all (“No function or component was found; the run will see module-level code only.”).

Input fields

This is exactly what the page sends (nextkit.js buildInput(lane, A, note, handoff), checked by mustBeObject; app.js currentInput and run), and what input-schema.json declares. The platform’s input schema is scalar-only, so every value is a string. buildInput emits them in the order task, code, facts, note, audit_handoff:

FieldTypeRequiredMeaning
taskstringyes"audit" or "fix" (the lanes above).
codestringyesThe pasted files, each introduced by a // file: <path> line (app/products/[id]/page.tsx, components/AddToCart.tsx, package.json); line 1 is its first line. Every line in an audit reply is counted in this text. A large paste is cut on whole files, with a // [routefence: not sent: ...] line at the end (below).
factsstringyesThe page’s free check, Nextkit.facts(A), JSON-encoded into a string: JSON.stringify(facts) in JavaScript, json.dumps(facts) in Python, and so on. Sending the object itself is a 400. The model is told to parse it.
audit_handoffstringnofix lane only: a JSON string built from an audit reply on this code (recon.js fixHandoff, below). buildInput drops it for the audit lane.
notestringnoWhat the engineer says: the goal, a constraint (“keep the /reports URL”, “we are on Next 14”, “no new packages”), a question. The page trims it, cuts it at 2000 characters and sends it only when it is not blank. It is context, not evidence and not instructions: the model tests every claim in it against code and answers it in summary.
retry_notestringnoSent only by the page’s automatic reformat retry: when a reply does not parse as JSON (even after the page tries to close a cut-off object), the page runs once more with “Your previous reply was not the single valid JSON object the instructions require (...). Reply again with ONLY the JSON object for task '<lane>' - no prose, no code fences; every array present (empty arrays where there is nothing to say).” A normal call never sends it.

mustBeObject only insists on a plain object that is not wrapped in {"input": ...}; it is buildInput that leaves a blank note out, which is why an empty note is simply absent.

The code string

code is the paste itself. A line // file: <path> (or # file: <path>; a path with a . or / in it) starts each file, and a --- line just before it only separates files. Files dropped on the page (or a dropped route folder, with node_modules and .next skipped) are joined that way by Nextkit.joinFiles: each file under its // file: line, trailing whitespace removed, joined by newlines, ending in a newline. A paste with no file line is read as one file, app/page.tsx (or package.json when it reads as a manifest). The page normalises line endings to \n. A .tsx, .ts, .jsx, .js, .mjs or .cjs path is code, and its path alone gives its role (page, layout, loading, error, route, template, default, not-found, middleware, or module); package.json is package (its next and react versions give facts.runtime, and the Next.js major decides how the request-API flags are graded: high on 16, medium on 15, low when unknown, none on 14); anything else is other. The paste keeps its own indentation. Send the text you want the reply to quote: every file in an audit reply must be a path from facts.files, every line must fall inside that file, every evidence must be copied from it, and every edit’s before must occur in it exactly once.

The page reads up to 400 000 characters (MAX_TEXT, cut at a line) and 40 files (MAX_FILES), and sends at most 60 000 characters (MAX_SEND): past that buildInput drops whole files from the end — the last file that is neither code nor package.json first, then the last code file outside app/, then the last file — until the rest fits, cuts a single remaining file at a line (“the end of <path>”) if it still does not, and ends the string with one line naming what it left out:

// [routefence: not sent: lib/format.ts, components/Gallery.tsx]

The model is told never to quote or rewrite what was not sent, and facts.notes gets the sentence “Not sent (the paste is over 60,000 characters): ... The page checked them; the run did not see them.” facts is still computed from the whole paste, and is capped too (about 40 000 characters, FACTS_BUDGET): long strings are clipped (ending in ...), at most 150 flags are sent — the most severe first, flags in files the run was sent before the rest — and while the facts are still too large the other lists give way before the flags. Every cut is named in facts.notes (“Facts cut to fit the run: sent 47 of 1701 flags ...”), and the page shows the same sentence by the run button. The worked examples below are small, so their code is the paste unchanged and their facts are uncut.

The facts string

facts is optional to compute but required to send. It is produced by the page’s free check: Nextkit.analyze splits the paste into files, lexes them, finds their functions, exports, imports and JSX tags, resolves the imports between them, works out each file’s boundary, inventories them and runs the rules, and Nextkit.facts(A) turns that analysis into the object below (it does not depend on the lane: an audit and a fix run of the same paste carry the same facts). It is how the model knows which flags to answer, which file paths and line ranges to name, and which exports must survive. Its top-level keys, in the order built:

KeyContents
runtimenext (the version range in a pasted package.json, or null), next_major (15, 16, ... or null when unknown), react, package_json_pasted, and counts: code_files, server_files, client_files, server_actions.
filesEvery file: path (use it verbatim), kind (code, package, other), line and end_line (the paste lines of the file’s content, without its // file: line), and for code: role, directive (client, server or null), boundary (server, client, both — imported from both sides — or unknown — not imported by any pasted route file) and boundary_why.
exportsEvery export (up to 200): name (default for the default export), file, line, kind (function or const), async.
functionsEvery named function or component (up to 200): name, file, line, async, exported, action (it is a Server Action).
client_importsEvery import of a 'use client' file from a file that is not one (up to 80): from, to, names, line.
client_propsEvery prop passed to a Client Component from a server file (up to 80): file, line, component, prop, value, kind (value, function, server action).
request_apisEvery read of params, searchParams, cookies(), headers(), draftMode() (up to 120): file, line, func, api, awaited, and how for a params/searchParams read that is not awaited.
server_actionsEvery Server Action (up to 60): file, line, name, where ('use server' file or inline 'use server'), async, mutates, auth (an authorization call was seen), revalidates.
segment_configEvery export const dynamic | revalidate | runtime | fetchCache | dynamicParams | maxDuration | preferredRegion (up to 40): file, line, name, value, valid.
flagsEvery flag the page raised: uid (F1, F2, ... in severity, file, then line order), id (NX-01..NX-18), sev (high | medium | low | info), file, line, text, detail, and lines (every line an aggregated flag covers, only when it covers more than one). An audit must answer every non-info flag by its uid; a fix must close every high and medium flag or name it in open.
verdict_candidateThe audit verdict the flags point at (the page’s own reading): blocked with any high flag, needs_work with any medium, otherwise ready.
notesPlain sentences about what was not read or not sent, including any list the facts cap cut (empty for a paste that fits).

The simplest source is nextkit.js itself: the page loads it from /nextkit.js, and it also runs in Node (it sets module.exports). This script prints what the page would send as code and facts (with the 60 000-character cut applied), with the facts decoded so you can read them; the step 4 samples compact them back into the page’s string byte for byte. Save the paste as storefront-paste.txt and redirect the output into storefront-check.json:

// build-check.js -- run: node build-check.js storefront-paste.txt > storefront-check.json   (nextkit.js saved next to it)
const fs = require("fs");
const Nextkit = require("./nextkit.js");
const A = Nextkit.analyze(fs.readFileSync(process.argv[2], "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
const inp = Nextkit.buildInput("audit", A, "", null);        // code and facts do not depend on the lane
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts) }, null, 2));
const real = A.flags.filter((f) => f.sev !== "info");
const codeFiles = A.files.filter((f) => f.kind === "code").length;
console.error(codeFiles + " code file(s), " + A.server_files + " on the server, " + A.client_files + " in the client bundle, " + A.actions.length + " Server Action(s); " +
  real.length + " flags (" + A.counts.high + " high, " + A.counts.medium + " medium, " + A.counts.low + " low); verdict_candidate " + A.candidate);

For the storefront example it reports 4 code file(s), 3 on the server, 2 in the client bundle, 0 Server Action(s); 5 flags (3 high, 2 medium, 0 low); verdict_candidate blocked; for the invites example, 3 code file(s), 2 on the server, 1 in the client bundle, 1 Server Action(s); 4 flags (2 high, 2 medium, 0 low); verdict_candidate blocked. For both worked examples its code and facts are exactly what the page sends.

Without the page’s check. The platform only checks that facts is a string, so a minimal object works — "{}", or a hand-built one such as {"flags": [], "notes": ["No page check was run; read the code directly."]} — and the model still reads code. But you lose what the check adds: with no flags there is nothing to confirm, dismiss or close, with no files there are no paths, line ranges, roles or boundaries to name, with no runtime the model does not know the Next.js major (and so whether params must be awaited), and with no exports nothing says what must survive a fix. Build it with nextkit.js as above when you can.

The audit_handoff string

Only for task: "fix", and only for the code the audit was about with every audit edit applied. The page builds both from the normalised audit reply res (recon.js normalize, below) and the analysis A of the audited paste, in recon.js fixHandoff:

const run = Recon.applyFindings(A, res.findings, Nextkit);   // every finding's edits, in order, each on top of the last
JSON.stringify({ verdict: res.verdict, headline: res.headline,
  findings: res.findings.map(f => ({ id: f.id, severity: f.severity, file: f.file, line: f.line, title: f.title, why: f.why, edits: f.edits.length })),
  applied: /* edits that applied */, failed: /* edits that did not */ })
// run.A.text is the edited paste: it becomes code, and facts is recomputed from it

That is {"verdict", "headline", "findings": [{id, severity, file, line, title, why, edits}], "applied", "failed"}, where each finding’s edits is the number of edits it carried. applyFindings applies every finding’s edits with Nextkit.applyEdits, in the order listed: each before must occur exactly once in its file as the earlier edits left it (leading and trailing blank lines ignored), or that edit is reported as failed and skipped — it is never guessed. The handoff is a previous reply, not evidence: the model checks it against code and fixes what is left. This script does the whole step — the edited paste, its recomputed facts and the handoff string, in the same shape as build-check.js prints, so the samples read it the same way:

// handoff.js -- run: node handoff.js storefront-paste.txt storefront-reply.txt > storefront-fix-check.json   (nextkit.js, recon.js next to it)
const fs = require("fs");
const Nextkit = require("./nextkit.js");
const Recon = require("./recon.js");
const A = Nextkit.analyze(fs.readFileSync(process.argv[2], "utf8"));           // the audited paste
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), "audit");
const ho = Recon.fixHandoff(A, res);                                            // every edit, in order
const B = Nextkit.analyze(ho.text);                                             // facts recomputed from the edited paste
const inp = Nextkit.buildInput("fix", B, "", ho.handoff);
console.log(JSON.stringify({ code: inp.code, facts: JSON.parse(inp.facts), audit_handoff: inp.audit_handoff }, null, 2));
console.error(ho.applied + " edit(s) applied, " + ho.failed + " did not apply");

Save the audit’s output.output as storefront-reply.txt; for the storefront audit below it reports 10 edit(s) applied, 0 did not apply. The edited paste then reads 4 code file(s), 3 on the server, 1 in the client bundle, 1 Server Action(s); 2 flags (0 high, 1 medium, 1 low); verdict_candidate needs_work: the inline Server Action the audit added is flagged because it writes with no authorization check and never revalidates, which is exactly what the audit’s own questions ask about. The page sends that fix run with the audit’s note and the key routefence:fix:b7b30637-2271:a1.

The output contracts

The model is instructed (SKILL.md) to reply with one JSON object and nothing else — no prose, no code fences — with every array of its lane present ([] when empty). The run’s output text is that object. In both lanes it must ground everything in code: a component, a helper (auth, db, getPost ...), a parent layout, middleware, next.config or a database schema that is not in the paste is never claimed to do anything — it goes into questions; whether a component imported from a file that was not pasted is a Client Component is unknown (“if Chart is a Client Component ...”). It must never write a secret value: a line that holds a literal credential is never copied into the reply (not in evidence, not in an edit, not in a file), and no credential, real hostname or endpoint is invented. It must never change what the route offers the rest of the app: every URL stays where it is (no page, route or layout file moved, renamed or deleted, no segment folder changed), every export keeps its name, and the props of a component that code outside the paste may render keep their names, unless the note asks for it (typing params as a Promise on Next.js 15+ and removing a Pages Router export are allowed). It uses an API only when it is sure the API exists with that name in that package and version, never states a caching default without naming the version, and never adds a package the paste does not already use unless the note allows it. Every page flag whose sev is not info is answered exactly once (audit lane: in flag_answers), and an info flag is never reported as a finding.

task: "audit"

task: "fix"

How the page reads a reply (recon.js parseResult and normalize): it strips a code fence and parses from the first { to the last }. A reply is read as the lane that was asked for, unless it names the other lane and carries that lane’s body (a files array for fix, a findings array for audit). An unknown verdict shows as needs_work or partial and counts as a disagreement; an unknown severity becomes medium; ids like NX7 or nx 7 become NX-07; flags keeps only F<n> values; findings with no file, evidence and title are dropped; route may also arrive as service or component, file as path, a change’s function as func and its change as what, and an open item as path; questions is capped at 10, plan at 30 and verify at 20. An audit reply carries 9 sections (verdict, headline, route, areas, findings, flag_answers, plan, questions, summary) and a fix reply 8 (verdict, headline, files, changes, verify, open, questions, summary), which is what the page counts when it recovers a partial reply.

Worked examples

Both are examples the page ships (the example buttons on the app page; the full pastes are in /example.js, ids storefront and invites), built exactly as the page builds them: Nextkit.buildInput(lane, Nextkit.analyze(text), note, null), with the real model reply the page replays for each. The pastes are shown whole; the request bodies cut the long code and facts strings with a visible ..., and the replies are pretty-printed excerpts, with ... where entries were left out. Every value shown is copied from the input or the reply. Neither paste holds a credential, and the imported helpers (@/lib/auth, @/lib/db in the invites example) are not part of the paste.

Audit: a product page on Next.js 15 that calls useState on the server, passes a function into a Client Component, and ships the Prisma client to the browser

Five files: package.json (next 15.2.4, react 19.0.0 and @prisma/client), app/products/[id]/page.tsx, components/AddToCart.tsx, lib/db.ts and lib/catalog.ts: a product page for a shop. ProductPage is an async Server Component that reads params.id and cookies() without await, calls useState, and passes its local function handleAdd to the Client Component AddToCart as onAdd; AddToCart ('use client') imports prisma from lib/db.ts and writes the cart row itself from the browser. The page’s free check raises 5 flags (3 high, 2 medium, 0 low) (plus the NX-18 context flag); its own reading of the verdict is blocked. The engineer’s note: “The build fails with "useState only works in Client Components" since we moved to Next 15. What else will break before we can ship the product page?”

The code string, whole: 1,818 characters, 71 lines ending in a newline. Save it as storefront-paste.txt and run node build-check.js storefront-paste.txt > storefront-check.json:

// file: package.json
{
  "name": "quaybrin-goods-web",
  "private": true,
  "dependencies": {
    "next": "15.2.4",
    "react": "19.0.0",
    "react-dom": "19.0.0",
    "@prisma/client": "6.4.1"
  }
}
// file: app/products/[id]/page.tsx
import { useState } from "react";
import { cookies } from "next/headers";
import { getProduct } from "@/lib/catalog";
import AddToCart from "@/components/AddToCart";

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id);
  const currency = cookies().get("currency")?.value ?? "USD";
  const [qty, setQty] = useState(1);

  function handleAdd(id: string) {
    console.log("added", id, qty);
  }

  return (
    <main>
      <h1>{product.name}</h1>
      <p>
        {product.price} {currency}
      </p>
      <AddToCart productId={product.id} onAdd={handleAdd} />
    </main>
  );
}
// file: components/AddToCart.tsx
"use client";

import { useTransition } from "react";
import { prisma } from "@/lib/db";

export default function AddToCart({ productId, onAdd }: { productId: string; onAdd: (id: string) => void }) {
  const [pending, startTransition] = useTransition();
  return (
    <button
      type="button"
      disabled={pending}
      onClick={() =>
        startTransition(async () => {
          await prisma.cartItem.create({ data: { productId } });
          onAdd(productId);
        })
      }
    >
      Add to cart
    </button>
  );
}
// file: lib/db.ts
import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient();
// file: lib/catalog.ts
import { prisma } from "./db";

export async function getProduct(id: string) {
  const product = await prisma.product.findUnique({ where: { id } });
  if (!product) throw new Error("not found");
  return product;
}

Its facts, decoded, without the exports and functions lists (the full object also names the 4 exports — the two default components, prisma and getProduct — and the 4 functions ProductPage, handleAdd, AddToCart and getProduct; the request carries it as one compact JSON string):

{
  "runtime": {
    "next": "15.2.4",
    "next_major": 15,
    "react": "19.0.0",
    "package_json_pasted": true,
    "code_files": 4,
    "server_files": 3,
    "client_files": 2,
    "server_actions": 0
  },
  "files": [
    {
      "path": "package.json",
      "kind": "package",
      "line": 2,
      "end_line": 11
    },
    {
      "path": "app/products/[id]/page.tsx",
      "kind": "code",
      "line": 13,
      "end_line": 36,
      "role": "page",
      "directive": null,
      "boundary": "server",
      "boundary_why": "page"
    },
    {
      "path": "components/AddToCart.tsx",
      "kind": "code",
      "line": 38,
      "end_line": 59,
      "role": "module",
      "directive": "client",
      "boundary": "client",
      "boundary_why": "directive"
    },
    {
      "path": "lib/db.ts",
      "kind": "code",
      "line": 61,
      "end_line": 63,
      "role": "module",
      "directive": null,
      "boundary": "both",
      "boundary_why": "imported by components/AddToCart.tsx"
    },
    {
      "path": "lib/catalog.ts",
      "kind": "code",
      "line": 65,
      "end_line": 71,
      "role": "module",
      "directive": null,
      "boundary": "server",
      "boundary_why": "imported by app/products/[id]/page.tsx"
    }
  ],
  "client_imports": [
    {
      "from": "app/products/[id]/page.tsx",
      "to": "components/AddToCart.tsx",
      "names": "AddToCart",
      "line": 16
    }
  ],
  "client_props": [
    {
      "file": "app/products/[id]/page.tsx",
      "line": 33,
      "component": "AddToCart",
      "prop": "productId",
      "value": "product.id",
      "kind": "value"
    },
    {
      "file": "app/products/[id]/page.tsx",
      "line": 33,
      "component": "AddToCart",
      "prop": "onAdd",
      "value": "handleAdd",
      "kind": "function"
    }
  ],
  "request_apis": [
    {
      "file": "app/products/[id]/page.tsx",
      "line": 19,
      "func": "ProductPage",
      "api": "params",
      "awaited": false,
      "how": "read without await"
    },
    {
      "file": "app/products/[id]/page.tsx",
      "line": 20,
      "func": "ProductPage",
      "api": "cookies()",
      "awaited": false
    }
  ],
  "server_actions": [],
  "segment_config": [],
  "flags": [
    {
      "uid": "F1",
      "id": "NX-01",
      "sev": "high",
      "file": "app/products/[id]/page.tsx",
      "line": 21,
      "text": "useState() in a Server Component (app/products/[id]/page.tsx is page)",
      "detail": "Without 'use client' this file renders on the server, where there is no state, no effect, no event and no window: hooks fail the build (\"You're importing a component that needs useState\"), an onClick on a DOM element fails at render (\"Event handlers cannot be passed to Client Component props\"), and window/document throw. Move the interactive part into a small Client Component (a leaf with 'use client') and keep the data fetching here; add 'use client' to this file only if all of it is interactive."
    },
    {
      "uid": "F2",
      "id": "NX-06",
      "sev": "high",
      "file": "app/products/[id]/page.tsx",
      "line": 33,
      "text": "function passed to the Client Component <AddToCart> as onAdd",
      "detail": "Props that cross from a Server Component into a Client Component are serialized; a plain function cannot be, so this fails at render (\"Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with 'use server'\"). Either make it a Server Action (an async function with 'use server'), move the handler into the Client Component, or pass the data the handler needs instead."
    },
    {
      "uid": "F3",
      "id": "NX-04",
      "sev": "high",
      "file": "components/AddToCart.tsx",
      "line": 41,
      "text": "Client Component imports lib/db.ts (which pulls in @prisma/client)",
      "detail": "Everything a 'use client' file imports is bundled for the browser: Node built-ins, database clients, next/headers and 'server-only' modules fail the build, and a secret-bearing SDK would ship its keys. Keep the server work in a Server Component or a Server Action ('use server' file) and pass the data down as props, or call the action from the client."
    },
    {
      "uid": "F4",
      "id": "NX-07",
      "sev": "medium",
      "file": "app/products/[id]/page.tsx",
      "line": 19,
      "text": "params read without await in ProductPage of page",
      "detail": "Since Next.js 15, params and searchParams are Promises: on Next.js 15 sync access still works but is deprecated and warns on every request; Next.js 16 removes it. Type them as Promise<{ ... }> and await them (const { slug } = await params) in a Server Component, or read them with React's use() in a Client Component."
    },
    {
      "uid": "F5",
      "id": "NX-08",
      "sev": "medium",
      "file": "app/products/[id]/page.tsx",
      "line": 20,
      "text": "cookies() called without await in ProductPage",
      "detail": "cookies(), headers() and draftMode() return Promises since Next.js 15: on Next.js 15 sync access still works but is deprecated and warns on every request; Next.js 16 removes it. Await them (const jar = await cookies()) - the enclosing function must be async."
    },
    {
      "uid": "F6",
      "id": "NX-18",
      "sev": "info",
      "file": "app/products/[id]/page.tsx",
      "line": 13,
      "text": "context: 4 code files (1 under app/), 3 on the server, 2 in the client bundle, 0 Server Action(s), next 15.2.4",
      "detail": "Also read: package.json (package). Only what is pasted is checked: a component imported from a file that was not pasted is outside the check (whether it is a Client Component is unknown), and so is what next.config, middleware and the parent layouts do."
    }
  ],
  "verdict_candidate": "blocked",
  "notes": []
}

The request body (POST /estimate, then POST /run or /run-stream with Idempotency-Key: routefence:audit:e64373f7-202f:a1 as the page sends it):

{
  "task": "audit",
  "code": "// file: package.json\n{\n  \"name\": \"quaybrin-goods-web\",\n  \"private\": true,\n  \"dependencies\": {\n    \"next\": \"15.2.4\",\n    \"react\": \"19.0.0\",\n    \"react-dom\": \"19.0.0\",\n    \"@prisma/client\": \"6.4.1\"\n  }\n}\n// file: app/products/[id]/page.tsx\nimport { useState } from \"react\";\nimport { cookies } from \"next/headers\";\n...",
  "facts": "{\"runtime\":{\"next\":\"15.2.4\",\"next_major\":15,\"react\":\"19.0.0\",\"package_json_pasted\":true,\"code_files\":4,\"server_files\":3,\"client_files\":2,\"server_actions\":0},\"files\":[{\"path\":\"package.json\",\"kind\":\"pac...",
  "note": "The build fails with \"useState only works in Client Components\" since we moved to Next 15. What else will break before we can ship the product page?"
}

The reply (output.output), an excerpt: the verdict, the headline, the route, the four areas, the finding that answers “what else will break” (the Prisma client in the browser) with its edits and its flag answer, the last plan step and the summary:

{
  "task": "audit",
  "verdict": "blocked",
  "headline": "The product page will not render at all: `useState` and a function prop break the Server Component, and `AddToCart` ships the Prisma client into the browser to write the cart row itself.",
  "route": "`app/products/[id]/page.tsx` is an async Server Component that awaits `getProduct` and reads a `currency` cookie, then renders the price and a client `AddToCart` button; the button (`components/AddToCart.tsx`, `'use client'`) is meant to write a cart row on click.",
  "areas": [
    {
      "area": "boundaries",
      "status": "broken",
      "note": "Three high-severity issues break the boundary: a hook and a function prop inside the Server Component, and the Client Component importing the Prisma client directly."
    },
    {
      "area": "data",
      "status": "fragile",
      "note": "`params` and `cookies()` are both read synchronously on Next 15.2.4, which still works today but warns on every request and breaks on Next 16."
    },
    {
      "area": "mutations",
      "status": "sound",
      "note": "No Server Action exists yet; the fix below adds one for the cart write, but it has no caller check or revalidation since neither auth nor a cart page was pasted."
    },
    {
      "area": "conventions",
      "status": "sound",
      "note": "File conventions and metadata are fine; `getProduct` throwing a bare `Error` for a missing product is a minor miss of Next's `notFound()` convention."
    }
  ],
  "findings": [
    ...,
    {
      "id": "NX-04",
      "flags": [
        "F3"
      ],
      "file": "components/AddToCart.tsx",
      "line": 41,
      "severity": "high",
      "area": "boundaries",
      "title": "Client Component imports the Prisma client",
      "evidence": "import { prisma } from \"@/lib/db\";",
      "why": "`AddToCart` has `'use client'` at the top, so everything it imports - including `lib/db.ts`'s `PrismaClient` - is bundled for the browser; `@prisma/client` needs Node.js APIs the browser doesn't have, so this fails the build, and the write was never going to run on the server this way.",
      "edits": [
        {
          "before": "import { useTransition } from \"react\";\nimport { prisma } from \"@/lib/db\";",
          "after": "import { useTransition } from \"react\";"
        },
        {
          "before": "export default function AddToCart({ productId, onAdd }: { productId: string; onAdd: (id: string) => void }) {",
          "after": "export default function AddToCart({ productId, onAdd }: { productId: string; onAdd: (id: string) => Promise<void> }) {"
        },
        {
          "before": "        startTransition(async () => {\n          await prisma.cartItem.create({ data: { productId } });\n          onAdd(productId);\n        })",
          "after": "        startTransition(async () => {\n          await onAdd(productId);\n        })"
        }
      ]
    },
    ...
  ],
  "flag_answers": [
    ...,
    {
      "flag": "F3",
      "status": "confirmed",
      "reason": "`components/AddToCart.tsx` imports `@/lib/db` directly; closed by calling the new Server Action instead."
    },
    ...
  ],
  "plan": [
    ...,
    "Run `next build` and `next lint`, then click through `/products/[id]` in `next dev` with the console open - including an id that doesn't exist - to confirm the boundary errors are gone and the not-found page renders."
  ],
  "questions": [
    "Should `addToCart` check who's adding to the cart (a session or user id), or is an anonymous/guest cart intended? No auth or session code was pasted, so the action can currently only validate that `productId` is a string.",
    ...
  ],
  "summary": "The `useState` crash isn't really a Next-15 migration issue - Server Components have never supported hooks or function props on any App Router version, so `ProductPage` would fail the same way on Next 14. Two more things will break before this ships: `AddToCart` imports `@/lib/db` and calls Prisma directly from the browser (build-breaking, and the wrong place for a database write), and `params`/`cookies()` are read synchronously, which only warns on 15.2.4 but will hard-fail on Next 16. None of the fixes touch the route's URL or exports; they move the button's state and the cart write into an inline Server Action instead."
}

The reply has 5 findings and confirms all 5 flags, with 10 edits in all. The first finding (NX-01, carrying F1 and F2) removes the useState import and the handleAdd function, adds an inline Server Action addToCart ("use server" as its first statement) that writes the cart row with prisma, and passes it as onAdd; the NX-07 finding types params as Promise<{ id: string }> and awaits it (F4); the NX-08 finding awaits cookies() (F5); the NX-04 finding shown above drops the prisma import from AddToCart and awaits onAdd instead (F3); and one low finding the page did not flag (other, flags: []) replaces throw new Error("not found") in getProduct with notFound(). Its questions ask whether addToCart should check who is adding to the cart and whether it should revalidate a cart page — which is why the re-check below flags the new action.

Fix: an invite form that passes a function into a Client Component, is an async Client Component, and swallows its redirect

Four files: package.json (next 15.2.0, react 19.0.0), app/team/invite/page.tsx, app/team/invite/InviteForm.tsx and app/team/invite/actions.ts. InvitePage reads the team cookie with cookies() without await and passes InviteForm the Server Action sendInvite as send (fine) and its local function trackOpen as onOpen (not fine); InviteForm is a 'use client' component declared async; sendInvite checks the session and the team, writes the invite, and calls redirect() inside a try whose catch returns an error message. The free check raises 4 flags (2 high, 2 medium, 0 low); its reading of the verdict is blocked. This example has no audit before it, so it carries no audit_handoff. The engineer’s note: “The invite page crashes with "Functions cannot be passed directly to Client Components". Fix everything the check found.”

The code string, whole: 2,154 characters, 76 lines ending in a newline. Save it as invites-paste.txt and run node build-check.js invites-paste.txt > invites-check.json:

// file: package.json
{
  "name": "crewvane",
  "private": true,
  "dependencies": {
    "next": "15.2.0",
    "react": "19.0.0",
    "react-dom": "19.0.0"
  }
}
// file: app/team/invite/page.tsx
import { cookies } from "next/headers";
import InviteForm from "./InviteForm";
import { sendInvite } from "./actions";

export default async function InvitePage() {
  const teamId = cookies().get("team")?.value;

  function trackOpen() {
    console.log("invite form opened", teamId);
  }

  return (
    <section>
      <h1>Invite a teammate</h1>
      <InviteForm teamId={teamId ?? ""} send={sendInvite} onOpen={trackOpen} />
    </section>
  );
}
// file: app/team/invite/InviteForm.tsx
"use client";

import { useActionState, useEffect } from "react";

type Send = (prev: string | null, form: FormData) => Promise<string | null>;

export default async function InviteForm({ teamId, send, onOpen }: { teamId: string; send: Send; onOpen: () => void }) {
  const [message, formAction, pending] = useActionState(send, null);

  useEffect(() => {
    onOpen();
  }, [onOpen]);

  return (
    <form action={formAction}>
      <input type="hidden" name="teamId" value={teamId} />
      <input type="email" name="email" required />
      <button type="submit" disabled={pending}>
        Send invite
      </button>
      {message && <p role="status">{message}</p>}
    </form>
  );
}
// file: app/team/invite/actions.ts
"use server";

import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

export async function sendInvite(prev: string | null, form: FormData): Promise<string | null> {
  const session = await auth();
  if (!session) return "Sign in first.";
  const email = String(form.get("email") ?? "");
  const teamId = String(form.get("teamId") ?? "");
  if (!session.user.teamIds.includes(teamId)) return "You are not on that team.";
  try {
    await db.invite.create({ data: { email, teamId, invitedBy: session.user.id } });
    revalidatePath("/team");
    redirect("/team?invited=1");
  } catch (e) {
    return "Could not send the invite.";
  }
}

Its facts, decoded, cut to files, client_props, request_apis, server_actions, flags and verdict_candidate (the full object also carries the runtime block — next 15.2.0, next_major 15, react 19.0.0 — the 3 exports, the functions and the one client import):

{
  "files": [
    {
      "path": "package.json",
      "kind": "package",
      "line": 2,
      "end_line": 10
    },
    {
      "path": "app/team/invite/page.tsx",
      "kind": "code",
      "line": 12,
      "end_line": 29,
      "role": "page",
      "directive": null,
      "boundary": "server",
      "boundary_why": "page"
    },
    {
      "path": "app/team/invite/InviteForm.tsx",
      "kind": "code",
      "line": 31,
      "end_line": 54,
      "role": "module",
      "directive": "client",
      "boundary": "client",
      "boundary_why": "directive"
    },
    {
      "path": "app/team/invite/actions.ts",
      "kind": "code",
      "line": 56,
      "end_line": 76,
      "role": "module",
      "directive": "server",
      "boundary": "server",
      "boundary_why": "use server"
    }
  ],
  "client_props": [
    {
      "file": "app/team/invite/page.tsx",
      "line": 26,
      "component": "InviteForm",
      "prop": "teamId",
      "value": "teamId ?? \"\"",
      "kind": "value"
    },
    {
      "file": "app/team/invite/page.tsx",
      "line": 26,
      "component": "InviteForm",
      "prop": "send",
      "value": "sendInvite",
      "kind": "server action"
    },
    {
      "file": "app/team/invite/page.tsx",
      "line": 26,
      "component": "InviteForm",
      "prop": "onOpen",
      "value": "trackOpen",
      "kind": "function"
    }
  ],
  "request_apis": [
    {
      "file": "app/team/invite/page.tsx",
      "line": 17,
      "func": "InvitePage",
      "api": "cookies()",
      "awaited": false
    }
  ],
  "server_actions": [
    {
      "file": "app/team/invite/actions.ts",
      "line": 63,
      "name": "sendInvite",
      "where": "'use server' file",
      "async": true,
      "mutates": true,
      "auth": true,
      "revalidates": true
    }
  ],
  "flags": [
    {
      "uid": "F1",
      "id": "NX-06",
      "sev": "high",
      "file": "app/team/invite/page.tsx",
      "line": 26,
      "text": "function passed to the Client Component <InviteForm> as onOpen",
      "detail": "Props that cross from a Server Component into a Client Component are serialized; a plain function cannot be, so this fails at render (\"Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with 'use server'\"). Either make it a Server Action (an async function with 'use server'), move the handler into the Client Component, or pass the data the handler needs instead."
    },
    {
      "uid": "F2",
      "id": "NX-05",
      "sev": "high",
      "file": "app/team/invite/InviteForm.tsx",
      "line": 37,
      "text": "async component InviteForm in a Client Component",
      "detail": "Client Components cannot be async functions: React rejects them (\"async/await is not yet supported in Client Components\") and an await in render would refetch on every render. Fetch in a Server Component parent and pass the result as a prop, pass a promise and read it with use(), or move the fetch into an event handler."
    },
    {
      "uid": "F3",
      "id": "NX-08",
      "sev": "medium",
      "file": "app/team/invite/page.tsx",
      "line": 17,
      "text": "cookies() called without await in InvitePage",
      "detail": "cookies(), headers() and draftMode() return Promises since Next.js 15: on Next.js 15 sync access still works but is deprecated and warns on every request; Next.js 16 removes it. Await them (const jar = await cookies()) - the enclosing function must be async."
    },
    {
      "uid": "F4",
      "id": "NX-13",
      "sev": "medium",
      "file": "app/team/invite/actions.ts",
      "line": 72,
      "text": "redirect() inside try in sendInvite, and the catch does not rethrow",
      "detail": "redirect() works by throwing a special error that Next.js catches. Inside this try, the catch receives it instead: the redirect or 404 never happens and the catch's error path runs. Call redirect() after the try/catch, or rethrow with unstable_rethrow(error) as the first line of the catch."
    },
    {
      "uid": "F5",
      "id": "NX-18",
      "sev": "info",
      "file": "app/team/invite/page.tsx",
      "line": 12,
      "text": "context: 3 code files (3 under app/), 2 on the server, 1 in the client bundle, 1 Server Action(s), next 15.2.0",
      "detail": "Also read: package.json (package). Only what is pasted is checked: a component imported from a file that was not pasted is outside the check (whether it is a Client Component is unknown), and so is what next.config, middleware and the parent layouts do."
    }
  ],
  "verdict_candidate": "blocked"
}

The request body (Idempotency-Key: routefence:fix:a3f331a4-1e45:a1):

{
  "task": "fix",
  "code": "// file: package.json\n{\n  \"name\": \"crewvane\",\n  \"private\": true,\n  \"dependencies\": {\n    \"next\": \"15.2.0\",\n    \"react\": \"19.0.0\",\n    \"react-dom\": \"19.0.0\"\n  }\n}\n// file: app/team/invite/page.tsx\nimport { cookies } from \"next/headers\";\nimport InviteForm from \"./InviteForm\";\n...",
  "facts": "{\"runtime\":{\"next\":\"15.2.0\",\"next_major\":15,\"react\":\"19.0.0\",\"package_json_pasted\":true,\"code_files\":3,\"server_files\":2,\"client_files\":1,\"server_actions\":1},\"files\":[{\"path\":\"package.json\",\"kind\":\"pac...",
  "note": "The invite page crashes with \"Functions cannot be passed directly to Client Components\". Fix everything the check found."
}

The reply (output.output), an excerpt: the verdict, the headline, the first of the three returned files, the change that stops the crash, every verification step and the (empty) open list:

{
  "task": "fix",
  "verdict": "fixed",
  "headline": "Removed the function prop crashing InviteForm's render, made InviteForm a sync Client Component, awaited cookies(), and moved redirect() out of the try/catch that was swallowing it.",
  "files": [
    {
      "path": "app/team/invite/page.tsx",
      "content": "import { cookies } from \"next/headers\";\nimport InviteForm from \"./InviteForm\";\nimport { sendInvite } from \"./actions\";\n\nexport default async function InvitePage() {\n  const cookieStore = await cookies();\n  const teamId = cookieStore.get(\"team\")?.value;\n\n  return (\n    <section>\n      <h1>Invite a teammate</h1>\n      <InviteForm teamId={teamId ?? \"\"} send={sendInvite} />\n    </section>\n  );\n}\n"
    },
    ...
  ],
  "changes": [
    {
      "file": "app/team/invite/page.tsx",
      "function": "InvitePage",
      "flags": [
        "F1"
      ],
      "change": "Removed the trackOpen function and the onOpen prop passed into InviteForm; the open-tracking log now runs inside InviteForm itself",
      "why": "A plain function cannot be serialized from a Server Component into a Client Component prop, which crashed the render"
    },
    ...
  ],
  "verify": [
    "Run `next build` and confirm no boundary, prop-serialization or async-Client-Component errors are reported for this route",
    "Run `next lint`",
    "Sign in as a team member, submit the invite form, and confirm the browser navigates to /team?invited=1 instead of showing \"Could not send the invite.\""
  ],
  "open": [],
  "questions": [],
  "summary": ...
}

The other three changes make InviteForm a plain (not async) function and move the open log into its own useEffect keyed on teamId (F2), await cookies() in InvitePage (F3), and move redirect("/team?invited=1") in sendInvite to after the try/catch so it is no longer swallowed (F4). The verdict is fixed because every flag is closed and open is empty; package.json needed no change, so it is not returned.

The page checks the reply against the code; the API does not

What comes back from the API is the model’s raw text in output.output. The page holds it to account in the browser before showing it (recon.js reconcile, with the facts, the code and the handoff it sent): it checks every quote against the paste, applies every edit or returned file to the paste and re-runs its own checks on the result. None of that happens in the API. A direct API caller gets the reply unchecked and must do the applying and re-checking itself.

recon.js also runs in Node, so you can hold a reply to exactly the page’s standard. Save /nextkit.js and /recon.js next to this script, pass the file you built the body from (build-check.js or handoff.js output), the reply text and the lane; it prints every statement, the count, and the paste with every edit (or file) put back:

// recheck.js -- run: node recheck.js storefront-check.json storefront-reply.txt audit > storefront-edited.txt   (nextkit.js, recon.js next to it)
const fs = require("fs");
const Recon = require("./recon.js");                                            // loads ./nextkit.js itself
const sent = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));             // code, facts (and audit_handoff) you sent
const lane = process.argv[4] || "audit";
const res = Recon.normalize(Recon.parseResult(fs.readFileSync(process.argv[3], "utf8")), lane);  // output.output
const r = Recon.reconcile(res, { facts: sent.facts, code: sent.code, handoff: sent.audit_handoff || null });
r.notes.forEach((n) => console.error(n.kind + ": " + n.text));
console.error(r.disagreements + " disagreement(s) in " + r.statements + " statements checked");
if (r.after) (r.after.flags || []).forEach((f) => console.error("still raised: " + f.id + " " + f.sev + " " + f.file + ":" + f.line + " " + f.text));
if (r.after && r.after.text) console.log(r.after.text.replace(/\n$/, ""));      // the paste with every edit (or file) put back

For the storefront audit reply it reports:

ok: The areas table rates boundaries, data, mutations and conventions once each.
ok: Every one of the 5 findings names a pasted file, a line inside it and evidence copied from it.
ok: The verdict (blocked) fits the worst finding.
ok: Every one of the page's 5 flags is answered once.
ok: All 10 edits applied in order, each before text found exactly once.
ok: Every confirmed flag a finding carries is gone once all edits are applied.
ok: Every export of the paste is still there after the edits.
warn: With every edit applied the page raises 1 new medium flag (NX-11: Server Action addToCart writes data with no authorization check).
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 9 statements checked
still raised: NX-11 medium app/products/[id]/page.tsx:23 Server Action addToCart writes data with no authorization check
still raised: NX-12 low app/products/[id]/page.tsx:23 Server Action addToCart writes data and never revalidates or redirects

The warning is the page doing its job: the inline Server Action the audit wrote writes a cart row with no authorization check (NX-11) and never revalidates (NX-12). The reply says as much in its questions; the handoff to the fix lane carries those two flags into the next run.

and for the invites fix reply (node recheck.js invites-check.json invites-reply.txt fix):

ok: 3 files returned whole.
ok: With the fixed files in place the free check raises no high or medium flag (0 low).
ok: Every one of the 3 exports is still there.
ok: Every one of the 4 changes names a returned file, a function in the fixed code and real page flags.
ok: The verdict (fixed) fits the open list.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 6 statements checked

The page does not build or run the code. After that, run next build (it reports every boundary and metadata error at once) and next lint, plus the checks the reply’s plan or verify names, and click through the route in next dev with the browser console open, before you ship anything.

Step 1 — a tiny client helper

One function per language that does what sdk.js does for every call: JSON in, bearer token if you have one, data out on a 2xx, an error carrying status, error.code and error.message otherwise. The later steps assume this helper is in the same file (for Go, Java and C#, the later snippets go inside main / the top-level statements; Java’s makeInput from step 4 goes in the class).

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:-YOUR_TOKEN}"   # step 2

# api METHOD PATH [extra curl args...] -- prints the whole envelope; pipe to jq
api() {
  method="$1"; path="$2"; shift 2
  curl -sS -X "$method" "$BASE$path" \
    -H "Content-Type: application/json" \
    -H "Authorization: Bearer $TOKEN" "$@"
}
# success: jq '.data'      failure: jq '.error | {code, message}'

Step 2 — a token

Two kinds work:

# personal token (needed for a run): copy it from /tokens.html, then
#   export SKILLSAFE_TOKEN=...        and skip the rest of this block
# guest token (enough for /me and /estimate): no Authorization header on this one call
TOKEN=$(curl -sS -X POST "$BASE/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"routefence"}' | jq -r '.data.token')
echo "guest token: ${TOKEN:0:12}..."

Step 3 — GET /me

Who the token belongs to and what it can spend. sdk.js documents the shape as {subject_type, subject_id, credits, profile?}. The page treats the caller as signed in only when subject_type is "user", shows credits as the balance (1 credit = $0.0001, so it divides by 10 000 for the dollar figure), and compares it with the estimate in step 4 before it lets you run.

api GET /me | jq '.data // .error'

Step 4 — build the input, then POST /estimate

First a small makeInput that does what buildInput does: task, then code as the paste the page would send, facts as a compact JSON string, the note when it is not blank, and — for the fix lane only — the audit_handoff string when the file carries one. It reads the file build-check.js (or handoff.js) printed. The samples build the audit input of the storefront example from storefront-check.json; step 6 reuses makeInput for the invites (fix) example. Then price it. The body of /estimate is the same input you will send to /run; no job is created and nothing is charged — /estimate is free. From the response the page reads:

FieldMeaning
hold_creditsA reservation, not a price: the worst case the platform holds while the run is in flight. The page shows it as “reserves N credits ($...) for the audit run” and adds that you are charged only for what the run actually uses, usually far less. What you pay is the job’s charged_credits after settlement (step 5). A balance below the hold is allowed, but the page warns that the reply may be cut short (truncated: true).
min_creditsThe smallest balance that can start a run. Below it the page disables the run button and names the shortfall, because the call would be a 402.
sponsor_enabledWhen true, a caller who is not signed in may run; otherwise the page asks them to sign in.
warningsInput warnings. They do not refuse the call; the page shows them next to the price (“input warnings: ...”), so check them before you run.

The page re-prices whenever the paste, the note or the lane change. The amounts grow with the size of code and facts, so read them from the call rather than hard-coding them.

# storefront-check.json: printed by build-check.js (see "The facts string").
# (.facts | tojson) turns the facts into ONE compact string, as JSON.stringify does.
BODY=$(jq -c --arg note "The build fails with \"useState only works in Client Components\" since we moved to Next 15. What else will break before we can ship the product page?" \
  '{task: "audit", code, facts: (.facts | tojson), note: $note}' storefront-check.json)
# No page check at hand? facts: "{}" is accepted as a string, but the reply then has no page flags to answer.
# Fix lane after an audit: read handoff.js's output instead and add its string:
#   '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)'

api POST /estimate -d "$BODY" \
  | jq '.error // (.data | {hold_credits, min_credits, sponsor_enabled, warnings})'

Step 5 — POST /run, then poll the job (audit)

Send the same input to /run with an Idempotency-Key header, using a personal token. Its data carries a job_id. Poll GET /jobs/{job_id} the way sdk.js waitForJob does: every second until status is succeeded or failed, giving up on the client side after 180 seconds. On the finished job, read:

Idempotency key. Resending a request with the same key must not start a second paid run, so derive the key from the input, not from the clock — and include the lane, so an audit and a fix run of the same paste never share a key. The page uses routefence:<lane>:<inputHash>:a<attempt> (app.js run), where <lane> is audit or fix, <inputHash> is a 32-bit djb2 hash of JSON.stringify(input) in hex, a dash, and the string’s length in hex (app.js inputHash, over the input without retry_note), and <attempt> is 1 for a normal run and 2 for the automatic reformat retry (which also adds retry_note). For the two worked examples that is routefence:audit:e64373f7-202f:a1 (storefront audit) and routefence:fix:a3f331a4-1e45:a1 (invites fix); the fix run of the storefront audit handed on (above) is routefence:fix:b7b30637-2271:a1. The format is the page’s convention, not a requirement: any stable, unique string does. The samples below use the lane and a SHA-256 prefix.

The page itself uses /run-stream (step 6), not /run; both take the same body and header.

KEY="routefence:audit:$(printf '%s' "$BODY" | shasum -a 256 | cut -c1-16):a1"

JOB=$(api POST /run -H "Idempotency-Key: $KEY" -d "$BODY" | jq -r '.data.job_id')

for i in $(seq 180); do                       # 1 s interval, 180 s client-side limit
  J=$(api GET "/jobs/$JOB")
  S=$(printf '%s' "$J" | jq -r '.data.status')
  if [ "$S" = "succeeded" ] || [ "$S" = "failed" ]; then break; fi
  sleep 1
done

printf '%s' "$J" | jq '.data | {status, truncated, charged_credits, error}'
printf '%s' "$J" | jq -r '.data.output.output' \
  | jq '{verdict, headline, areas: [(.areas // [])[] | {area, status}],
         findings: [(.findings // [])[] | {id, file, line, severity, edits: ((.edits // []) | length)}],
         flag_answers: [(.flag_answers // [])[] | {flag, status}]}'

Step 6 — POST /run-stream (Server-Sent Events, fix)

The alternative to step 5, and what the page uses: same kind of body, same Idempotency-Key header, one request. Run one or the other for a given input, not both. To show the second lane, the samples build the fix input of the invites example (with its own key) and stream it; to stream the audit input instead, pass the step 4 input and key. The response is text/event-stream. sdk.js splits it into frames on a blank line, takes the event name from an event: line (default message) and concatenates the data: lines, skips frames with no data or with data that is not JSON, and handles these events:

EventDataWhat the SDK does
delta{text}Passes text to onDelta.
jobjob infoPasses it to onJob (the page uses it to advance its progress display).
donethe final payload: {job_id, status, charged_credits, output}Keeps it as the result.
pendinga payloadTreated exactly like done. If it carries a job_id and no output, poll that job as in step 5.
error{message, code, job_id}After the stream ends, raises an error with those fields.

If the response is not text/event-stream, it is an ordinary JSON envelope: the SDK returns its data (an idempotent replay returns this way) or raises the error on a non-2xx status. Handle both shapes.

From the final payload the page reads output.output (falling back to the concatenated delta text), truncated and charged_credits. Browsers receive progress ticks rather than deltas from this endpoint, so the page relies on the final payload; if the stream fails part-way, it tries to recover a partial reply from whatever delta text arrived. The samples print the verdict and headline, each returned file’s path and size, each change (function, flags and change), each verification step, each open item and each question. To put the files into the paste and re-check them the way the page’s “Put every file into the paste” does, keep the reply text and run recheck.js with the lane fix. To fix code you audited, read handoff.js’s output instead of invites-check.json: makeInput then carries its audit_handoff.

# the fix worked example: invites-check.json from build-check.js
# (for audited code, read handoff.js's output instead; the + (...) adds its audit_handoff)
FBODY=$(jq -c --arg note "The invite page crashes with \"Functions cannot be passed directly to Client Components\". Fix everything the check found." \
  '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)' \
  invites-check.json)
FKEY="routefence:fix:$(printf '%s' "$FBODY" | shasum -a 256 | cut -c1-16):a1"

# -N turns off buffering; the whole stream lands in $STREAM
STREAM=$(api POST /run-stream -N -H "Idempotency-Key: $FKEY" -d "$FBODY")

# frames look like:
#   event: job
#   data: {...}
#
#   event: done
#   data: {"job_id":"...","status":"...","charged_credits":...,"output":{"output":"{\"task\":\"fix\",...}"}}

# the final reply text, from the done (or pending) frame:
REPLY=$(printf '%s\n' "$STREAM" \
  | awk '/^event:/{ev=$2} /^data:/ && (ev=="done" || ev=="pending"){sub(/^data: */,""); print}' \
  | jq -r '.output.output')
# an idempotent replay answers with a plain JSON envelope instead: REPLY=$(printf '%s' "$STREAM" | jq -r '.data.output.output')
printf '%s' "$REPLY" | jq '{verdict, headline,
  files: [(.files // [])[] | {path, characters: (.content | length)}],
  changes: [(.changes // [])[] | {function, flags, change}], verify, open, questions}'

What the page does that you now do

Or skip all of this and use Routefence in the browser: the check, the flags, the file boundaries, the props into Client Components, the request-API and Server Action inventories, putting an edit or the fixed files back into the paste, and the Markdown and CSV exports are free.