← Scriptward / API
Tokens

Drive Scriptward from your own code

The page reads pasted GDScript in the browser for free — one or more files joined into one paste, each introduced by a # file: <path> line, plus a .tscn scene for the node tree and project.godot for the Godot version and the autoloads — with its line numbers. It lexes strings and comments out first, rebuilds every block from indentation (and notes per file whether it indents with tabs or spaces), inventories every func and whether it is typed, every member variable and when it is initialised, every node lookup ($Path, %Unique, get_node, group searches) and whether it runs every frame, every signal declared, connected, emitted and disconnected, and — from a pasted scene — which nodes really exist and which script sits on which node, and runs 19 deterministic checks drawn from Godot 4 practice (catalogue ids GD-01..GD-19, plus GD-20 for context): Godot 3 syntax and APIs that Godot 4 refuses (onready and export keywords, yield(), setget, KinematicBody2D, .instance(), rand_range() and more), an @export with no type or default and files that mix tabs and spaces, nodes fetched in a member initialiser without @onready, $Paths and %Unique names that are not in the pasted scene, node lookups and tree searches every frame, physics movement in _process, movement per frame without delta, a RigidBody moved directly, signals connected to handlers that do not exist or connected every frame, lambdas left on long-lived signals, signals and methods wired by string names, free() where queue_free() belongs, load() or instantiate() on the per-frame path, absolute /root/ paths to autoloads, credentials in scripts, untyped declarations, empty per-frame callbacks and signals never emitted. Nothing is run, and it is not the Godot editor: 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 script(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 Gdkit.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 scripts the way a senior Godot developer reviews a scene’s scripts before a release build: says what the scripts do and what runs every frame, rates the four areas (syntax, lifecycle, signals, resources) 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 a script from loading first, then what crashes on first use (member initialisers, missing nodes and handlers), then per-frame costs and physics, then signal wiring and freeing, then hygiene — with a plan that ends in running the scene with the Debugger’s Errors tab open and the GDScript warnings on (Project Settings > Debug > GDScript, for example untyped_declaration), watching the Profiler for the frame time.blocked | needs_work | readyBefore a release build: will Godot 4 load it, does it crash on first use, what does it pay every frame, and what should change first.
"fix"Writes the fixed version of the pasted code: the whole of every script it changes (and at most one new script, such as an object pool), in Godot 4 syntax with typed declarations, @onready references in place of per-frame lookups, physics in _physics_process, delta on every step, signals connected once to handlers that exist, queue_free, preload and pools — plus a changes list tied to the page flags, the verify steps (running the scene with the Debugger’s Errors tab open, and the check that would have caught the worst problem) and what it left open. It returns scripts only, never a .tscn, .tres or project.godot: a change the scene or a project setting needs goes into open. No func, signal, @export or class_name is renamed or removed, and no parameter changes, unless the note asks for it.fixed | partialWhen you want the scripts changed, not a list of findings: a port 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 as GDScript (“Nothing was pasted.”, “No GDScript file was found. Paste .gd source, with a # file: <path>.gd line above each file (a .tscn scene or project.godot may come too).”, or “This does not read as GDScript: no extends, func, var or signal was found.”), and it warns before a run on a paste with no func (“No func was found; the run will see member declarations only.”) or with $ and % lookups but no scene (“No .tscn scene was pasted, so node paths are not checked against the real node tree - paste the scene too for that check.”).

Input fields

This is exactly what the page sends (gdkit.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 code, each file introduced by a # file: <path> line; 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 # [scriptward: not sent: ...] line at the end (below).
factsstringyesThe page’s free check, Gdkit.facts(A) bounded by Gdkit.boundFacts (on a very large paste each list keeps its first entries, flags most severe first, and notes says what was left out), 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 developer says: the goal, a constraint (“keep the public API”, “we are on Godot 4.2”), 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. There is no slug header: the token and the body are all the run needs.

The code string

code is the paste itself. A line # 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 scene folder) are joined that way by Gdkit.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 script, script.gd. The page normalises line endings to \n. A .gd path is checked as GDScript (gdscript); .tscn and .escn are scene (the node tree, the node each script sits on and its %Unique names, so a $Path can be checked against the nodes that exist); project.godot is project (its config/features give facts.project.godot_version, or config_version gives 4.x or 3.x, and its [autoload] section the autoload names); .tres is resource; anything else is other. GDScript refuses a file that indents with both tabs and spaces, so the page keeps every file’s own indentation character and reports it as facts.files[].indent. 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 not a script first (scenes and project.godot), 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:

# [scriptward: not sent: levels/level_1.tscn, enemies/turret.gd]

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. The worked examples below are small, so their code is the paste unchanged.

The facts string

facts is optional to compute but required to send. It is produced by the page’s free check: Gdkit.analyze splits the paste into files, lexes them, rebuilds their blocks, reads any scene and project.godot, inventories the scripts and runs the rules, and Gdkit.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, which indentation character to write, and which funcs and signals must survive. Its top-level keys, in the order built:

KeyContents
projectgodot_version (from a pasted project.godot, or null when none was pasted), counts — scripts, scenes, functions, signals, connections, node_lookups — and autoloads (name, path).
filesEvery file: path (use it verbatim), kind (gdscript, scene, project, resource, other), line and end_line (the paste lines of the file’s content, without its # file: line), and for a script indent: tabs, spaces, mixed or none.
scriptsPer script (up to 40): file, extends, base (the built-in class it ends up extending, when the chain is in the paste), class_name, tool, callbacks (the engine callbacks it defines), attached_to (scene:node it sits on, from a pasted scene).
functionsEvery func (up to 200): name (Inner.func for an inner class; var.set/var.get for property accessors), file, line, params, every_frame (it is _process, _physics_process or _integrate_forces).
signalsEvery signal declared (up to 80): name, file, line, emitted (anywhere in the paste).
connectionsEvery connect() (up to 120): file, line, func, source (the object whose signal it is), signal, handler, form (method, method_other, lambda, callable_string, string_signal, godot3), every_frame.
lookupsEvery node lookup (up to 160): file, line, func ((member) for a member initialiser), kind ($, %, get_node, get_node_or_null, find_child, get_nodes_in_group, ...), path, every_frame, member_init.
scenesPer pasted scene (up to 10): file, root, root_type, nodes (the count), scripts (node <- script), unique (the % names).
flagsEvery flag the page raised: uid (F1, F2, ... in severity, file, then line order), id (GD-01..GD-20), 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 (empty for a paste that fits).

The simplest source is gdkit.js itself: the page loads it from /gdkit.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 player-paste.txt and redirect the output into player-check.json:

// build-check.js -- run: node build-check.js player-paste.txt > player-check.json   (gdkit.js saved next to it)
const fs = require("fs");
const Gdkit = require("./gdkit.js");
const A = Gdkit.analyze(fs.readFileSync(process.argv[2], "utf8"));
if (!A.ok) { console.error(A.errors.join("\n")); process.exit(1); }
const inp = Gdkit.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");
console.error(A.files.length + " file(s), " + A.scripts.length + " script(s), " + A.functions.length + " func(s), " + A.signals.length + " signal(s), " + A.connects.length + " connection(s), " + A.lookups.length + " lookup(s), " + A.scenes.length + " scene(s); " +
  real.length + " flags (" + A.counts.high + " high, " + A.counts.medium + " medium, " + A.counts.low + " low); verdict_candidate " + A.candidate);

For the player example it reports 2 file(s), 1 script(s), 4 func(s), 2 signal(s), 2 connection(s), 6 lookup(s), 1 scene(s); 9 flags (5 high, 2 medium, 2 low); verdict_candidate blocked; for the turret example, 2 file(s), 1 script(s), 3 func(s), 1 signal(s), 0 connection(s), 2 lookup(s), 1 scene(s); 4 flags (0 high, 4 medium, 0 low); verdict_candidate needs_work. 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 or indentation characters to follow, with no scenes no node path is known to exist, and with no functions and signals nothing says what must survive a fix. Build it with gdkit.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, Gdkit);      // 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 Gdkit.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). A before that is not found as written but is found exactly once re-indented to the file’s own tabs or spaces applies with its after re-indented the same way, and is counted as loose; anything else 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 player-paste.txt player-reply.txt > player-fix-check.json   (gdkit.js, recon.js next to it)
const fs = require("fs");
const Gdkit = require("./gdkit.js");
const Recon = require("./recon.js");
const A = Gdkit.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 = Gdkit.analyze(ho.text);                                               // facts recomputed from the edited paste
const inp = Gdkit.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 player-reply.txt; for the player audit below it reports 15 edit(s) applied, 0 did not apply, the recomputed facts of the edited paste carry only the GD-20 context flag (verdict_candidate ready), and the page sends that fix run with the audit’s note and the key scriptward:fix:12f96ec6-25d8: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 node, a scene, an autoload, an input action, a parent script’s method or another script’s signal that is not in the paste is never claimed to exist — it goes into questions (“if EventBus is an autoload ...”, unless a pasted project.godot lists it). 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, URL or server is invented. It must never change what the code offers the rest of the project: the same funcs with the same names and parameters, the same signals with the same arguments, the same @export variables (scenes store their values by name), the same class_name and the same node paths unless a GD-04 finding is about that path, unless the note asks for it. Every line it writes is Godot 4 syntax with typed declarations, cached lookups and handlers that exist, and an API it is not sure exists in Godot 4 (or in facts.project.godot_version when that is known) is recommended in words rather than written. 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 GD7 or gd 7 become GD-07; flags keeps only F<n> values; findings with no file, evidence and title are dropped; file may also arrive 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, scene, 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 player and turret), built exactly as the page builds them: Gdkit.buildInput(lane, Gdkit.analyze(text), note, null), with the real model reply the page replays for each. The pastes are shown whole (the scripts indent with tabs, as pasted); 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.

Audit: a player controller ported from Godot 3 that Godot 4 refuses to load

Two files, scenes/player/player.gd (indented with tabs) and scenes/player/player.tscn, with no project.godot, so the Godot version is unknown. The script still uses onready var, yield() and rand_range(), exports hurt_sound with no type and no default, fetches $Hitbox in a member initialiser, connects the HurtTimer’s timeout to _on_hurt_timer_timeout, which is never written, moves the body with move_and_slide() in _process, and looks up $AnimatedSprite2D and $Camera2D there every frame — and the scene has no Camera2D. The page’s free check raises 9 flags (5 high, 2 medium, 2 low) (plus the GD-20 context flag); its own reading of the verdict is blocked. The developer’s note: “Porting the player from our Godot 3 prototype. It will not even open in 4.3 - what do we fix first?”

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

# file: scenes/player/player.gd
extends CharacterBody2D

signal health_changed(new_health)
signal died

const SPEED = 220.0
const JUMP_VELOCITY = -420.0

@export var max_health = 100
@export var hurt_sound

onready var anim = $AnimatedSprite2D
var hitbox = $Hitbox
var health = 100
var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")


func _ready():
	health = max_health
	hitbox.area_entered.connect(_on_hitbox_area_entered)
	$HurtTimer.timeout.connect(_on_hurt_timer_timeout)


func _process(delta):
	var direction = Input.get_axis("move_left", "move_right")
	velocity.x = direction * SPEED
	if not is_on_floor():
		velocity.y += gravity * delta
	if Input.is_action_just_pressed("jump") and is_on_floor():
		velocity.y = JUMP_VELOCITY
	move_and_slide()
	$AnimatedSprite2D.flip_h = direction < 0
	$Camera2D.offset.x = direction * 24.0


func take_damage(amount):
	health = max(health - amount, 0)
	emit_signal("health_changed", health)
	$HurtTimer.start()
	if health <= 0:
		died.emit()
		yield(get_tree().create_timer(0.6), "timeout")
		get_tree().reload_current_scene()


func _on_hitbox_area_entered(area):
	if area.is_in_group("spikes"):
		take_damage(rand_range(10, 20))
# file: scenes/player/player.tscn
[gd_scene load_steps=4 format=3 uid="uid://b6k2plyr7q"]

[ext_resource type="Script" path="res://scenes/player/player.gd" id="1_pl"]
[ext_resource type="SpriteFrames" path="res://scenes/player/player_frames.tres" id="2_fr"]

[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_body"]
radius = 7.0
height = 26.0

[node name="Player" type="CharacterBody2D"]
script = ExtResource("1_pl")

[node name="AnimatedSprite2D" type="AnimatedSprite2D" parent="."]
sprite_frames = ExtResource("2_fr")

[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CapsuleShape2D_body")

[node name="Hitbox" type="Area2D" parent="."]

[node name="HurtTimer" type="Timer" parent="."]
one_shot = true
wait_time = 0.4

Its facts, decoded (the request carries this object as one compact JSON string):

{
  "project": {
    "godot_version": null,
    "scripts": 1,
    "scenes": 1,
    "functions": 4,
    "signals": 2,
    "connections": 2,
    "node_lookups": 6,
    "autoloads": []
  },
  "files": [
    {
      "path": "scenes/player/player.gd",
      "kind": "gdscript",
      "line": 2,
      "end_line": 49,
      "indent": "tabs"
    },
    {
      "path": "scenes/player/player.tscn",
      "kind": "scene",
      "line": 51,
      "end_line": 74
    }
  ],
  "scripts": [
    {
      "file": "scenes/player/player.gd",
      "extends": "CharacterBody2D",
      "base": "CharacterBody2D",
      "class_name": null,
      "tool": false,
      "callbacks": [
        "_ready",
        "_process"
      ],
      "attached_to": [
        "scenes/player/player.tscn:."
      ]
    }
  ],
  "functions": [
    {
      "name": "_ready",
      "file": "scenes/player/player.gd",
      "line": 19,
      "params": [],
      "every_frame": false
    },
    {
      "name": "_process",
      "file": "scenes/player/player.gd",
      "line": 25,
      "params": [
        "delta"
      ],
      "every_frame": true
    },
    {
      "name": "take_damage",
      "file": "scenes/player/player.gd",
      "line": 37,
      "params": [
        "amount"
      ],
      "every_frame": false
    },
    {
      "name": "_on_hitbox_area_entered",
      "file": "scenes/player/player.gd",
      "line": 47,
      "params": [
        "area"
      ],
      "every_frame": false
    }
  ],
  "signals": [
    {
      "name": "health_changed",
      "file": "scenes/player/player.gd",
      "line": 4,
      "emitted": true
    },
    {
      "name": "died",
      "file": "scenes/player/player.gd",
      "line": 5,
      "emitted": true
    }
  ],
  "connections": [
    {
      "file": "scenes/player/player.gd",
      "line": 21,
      "func": "_ready",
      "source": "hitbox",
      "signal": "area_entered",
      "handler": "_on_hitbox_area_entered",
      "form": "method",
      "every_frame": false
    },
    {
      "file": "scenes/player/player.gd",
      "line": 22,
      "func": "_ready",
      "source": "$HurtTimer",
      "signal": "timeout",
      "handler": "_on_hurt_timer_timeout",
      "form": "method",
      "every_frame": false
    }
  ],
  "lookups": [
    {
      "file": "scenes/player/player.gd",
      "line": 13,
      "func": "(member)",
      "kind": "$",
      "path": "AnimatedSprite2D",
      "every_frame": false,
      "member_init": true
    },
    {
      "file": "scenes/player/player.gd",
      "line": 14,
      "func": "(member)",
      "kind": "$",
      "path": "Hitbox",
      "every_frame": false,
      "member_init": true
    },
    {
      "file": "scenes/player/player.gd",
      "line": 22,
      "func": "_ready",
      "kind": "$",
      "path": "HurtTimer",
      "every_frame": false,
      "member_init": false
    },
    {
      "file": "scenes/player/player.gd",
      "line": 33,
      "func": "_process",
      "kind": "$",
      "path": "AnimatedSprite2D",
      "every_frame": true,
      "member_init": false
    },
    {
      "file": "scenes/player/player.gd",
      "line": 34,
      "func": "_process",
      "kind": "$",
      "path": "Camera2D",
      "every_frame": true,
      "member_init": false
    },
    {
      "file": "scenes/player/player.gd",
      "line": 40,
      "func": "take_damage",
      "kind": "$",
      "path": "HurtTimer",
      "every_frame": false,
      "member_init": false
    }
  ],
  "scenes": [
    {
      "file": "scenes/player/player.tscn",
      "root": "Player",
      "root_type": "CharacterBody2D",
      "nodes": 5,
      "scripts": [
        ". <- res://scenes/player/player.gd"
      ],
      "unique": []
    }
  ],
  "flags": [
    {
      "uid": "F1",
      "id": "GD-02",
      "sev": "high",
      "file": "scenes/player/player.gd",
      "line": 11,
      "text": "@export var hurt_sound has no type and no default value",
      "detail": "Godot 4 cannot export a variable whose type it cannot know: the script fails to parse (\"Cannot use simple @export annotation with variable without type or initializer\"). Give it a type (var hurt_sound: float) or a default (var hurt_sound = 1.0)."
    },
    {
      "uid": "F2",
      "id": "GD-01",
      "sev": "high",
      "file": "scenes/player/player.gd",
      "line": 13,
      "text": "scenes/player/player.gd uses 3 Godot 3 constructs that Godot 4 does not have: onready var, yield(), rand_range()",
      "detail": "Godot 4 refuses to load the script (a parse error or an unknown identifier), so the scene that uses it does not run. Godot 4 forms: onready var -> @onready var; yield() -> await signal (await get_tree().create_timer(t).timeout); rand_range() -> randf_range().",
      "lines": [
        13,
        43,
        49
      ]
    },
    {
      "uid": "F3",
      "id": "GD-03",
      "sev": "high",
      "file": "scenes/player/player.gd",
      "line": 14,
      "text": "var hitbox is set from the scene tree when the script is created, before its children exist",
      "detail": "A member initialiser runs when the node is constructed, before it enters the tree and before its children are added: get_node fails (\"Node not found\") and hitbox is null, so the first use crashes. Mark it @onready (@onready var hitbox := ...) so it is set just before _ready()."
    },
    {
      "uid": "F4",
      "id": "GD-09",
      "sev": "high",
      "file": "scenes/player/player.gd",
      "line": 22,
      "text": "timeout is connected to _on_hurt_timer_timeout, which is not a func in scenes/player/player.gd",
      "detail": "GDScript resolves _on_hurt_timer_timeout when it compiles the script: an identifier that is not declared is a parse error, so the whole script fails to load. Add func _on_hurt_timer_timeout(...) with the signal's arguments, or fix the name."
    },
    {
      "uid": "F5",
      "id": "GD-04",
      "sev": "high",
      "file": "scenes/player/player.gd",
      "line": 34,
      "text": "$Camera2D names a node that is not in scenes/player/player.tscn",
      "detail": "The script sits on the scene root (Player) in scenes/player/player.tscn, and that scene has no node at Camera2D: the lookup fails at runtime (\"Node not found\") and returns null, so the first use crashes. Fix the path, or rename the node in the scene."
    },
    {
      "uid": "F6",
      "id": "GD-06",
      "sev": "medium",
      "file": "scenes/player/player.gd",
      "line": 32,
      "text": "_process moves the body with move_and_slide(), once per rendered frame",
      "detail": "_process runs once per rendered frame, at a variable rate; physics runs at a fixed tick in _physics_process. Moving a body or applying forces from _process makes movement depend on the frame rate, jitters against the physics step, and can tunnel through collisions. Move the body in _physics_process(delta)."
    },
    {
      "uid": "F7",
      "id": "GD-05",
      "sev": "medium",
      "file": "scenes/player/player.gd",
      "line": 33,
      "text": "_process looks up 2 nodes ($AnimatedSprite2D, $Camera2D) every frame",
      "detail": "Every $Path or get_node call walks the node path again, every frame; at 60 frames a second that is wasted work on the main thread and a hitch source as the scene grows. Cache the node once: @onready var sprite: Sprite2D = $Sprite2D (a group: cache it in _ready and refresh on node_added / a signal).",
      "lines": [
        33,
        34
      ]
    },
    {
      "uid": "F8",
      "id": "GD-17",
      "sev": "low",
      "file": "scenes/player/player.gd",
      "line": 10,
      "text": "scenes/player/player.gd: 12 of 12 declarations are untyped (var max_health, var hurt_sound, var hitbox, var health and more)",
      "detail": "Untyped variables, parameters and returns are Variants: the editor cannot catch a wrong type or a typo in a member name, autocompletion stops working, and the GDScript VM cannot use its faster typed instructions. Give each a type (var speed: float = 200.0, func take_damage(amount: int) -> void) or infer it with :=.",
      "lines": [
        10,
        11,
        14,
        15,
        16,
        19,
        25,
        37,
        47
      ]
    },
    {
      "uid": "F9",
      "id": "GD-12",
      "sev": "low",
      "file": "scenes/player/player.gd",
      "line": 39,
      "text": "scenes/player/player.gd wires emit_signal(\"health_changed\")",
      "detail": "A signal or method named by a string is only looked up when the line runs: a typo or a rename fails at runtime (or silently, for emit_signal), and the editor cannot find or rename it. Godot 4 has first-class signals and Callables: died.emit(), died.connect(_on_died), _on_died.call_deferred()."
    },
    {
      "uid": "F10",
      "id": "GD-20",
      "sev": "info",
      "file": "scenes/player/player.gd",
      "line": 2,
      "text": "context: 1 script, 4 func(s), 2 signal(s) declared, 2 connect() call(s), 6 node lookup(s), 1 scene(s), Godot version unknown (paste project.godot)",
      "detail": "Also read: scenes/player/player.tscn (scene). Only what is pasted is checked: scripts that extend or call code outside the paste, scenes that were not pasted and signals emitted elsewhere are outside the check."
    }
  ],
  "verdict_candidate": "blocked",
  "notes": []
}

The request body (POST /estimate, then POST /run or /run-stream with Idempotency-Key: scriptward:audit:a1de78f0-27ad:a1 as the page sends it):

{
  "task": "audit",
  "code": "# file: scenes/player/player.gd\nextends CharacterBody2D\n\nsignal health_changed(new_health)\nsignal died\n\nconst SPEED = 220.0\nconst JUMP_VELOCITY = -420.0\n\n@export var max_health = 100\n@export var hurt_sound\n\nonready var a...",
  "facts": "{\"project\":{\"godot_version\":null,\"scripts\":1,\"scenes\":1,\"functions\":4,\"signals\":2,\"connections\":2,\"node_lookups\":6,\"autoloads\":[]},\"files\":[{\"path\":\"scenes/player/player.gd\",\"kind\":\"gdscript...",
  "note": "Porting the player from our Godot 3 prototype. It will not even open in 4.3 - what do we fix first?"
}

The reply (output.output), an excerpt: the verdict, the headline, each area’s status, and the finding that answers “it will not even open in 4.3” for the Godot 3 leftovers, with its three edits and its flag answer:

{
  "task": "audit",
  "verdict": "blocked",
  "headline": "The script won't load in Godot 4.3 yet - an untyped export, three Godot 3 leftovers, an undefined signal handler, a null member reference and a missing Camera2D node all block it before anything else matters.",
  "scene": ...,
  "areas": [
    {
      "area": "syntax",
      "status": "broken"
    },
    {
      "area": "lifecycle",
      "status": "broken"
    },
    {
      "area": "signals",
      "status": "broken"
    },
    {
      "area": "resources",
      "status": "sound"
    }
  ],
  "findings": [
    ...,
    {
      "id": "GD-01",
      "flags": [
        "F2"
      ],
      "file": "scenes/player/player.gd",
      "line": 13,
      "severity": "high",
      "area": "syntax",
      "title": "Three Godot 3 constructs stop the script from parsing",
      "evidence": "onready var anim = $AnimatedSprite2D\n\t\tyield(get_tree().create_timer(0.6), \"timeout\")\n\t\ttake_damage(rand_range(10, 20))",
      "why": "onready var, yield() and rand_range() no longer exist in Godot 4: the parser stops on the first one it hits, so nothing past it runs and the scene using this script cannot start.",
      "edits": [
        {
          "before": "onready var anim = $AnimatedSprite2D",
          "after": "@onready var anim: AnimatedSprite2D = $AnimatedSprite2D"
        },
        {
          "before": "\t\tyield(get_tree().create_timer(0.6), \"timeout\")",
          "after": "\t\tawait get_tree().create_timer(0.6).timeout"
        },
        {
          "before": "\t\ttake_damage(rand_range(10, 20))",
          "after": "\t\ttake_damage(randi_range(10, 20))"
        }
      ]
    },
    ...
  ],
  "flag_answers": [
    ...,
    {
      "flag": "F2",
      "status": "confirmed",
      "reason": "onready var, yield() and rand_range() are Godot 3 only; converted to @onready var, await and randi_range()."
    },
    ...
  ],
  "plan": [
    ...,
    "Run the scene (F6) with the Debugger's Errors tab open and GDScript warnings on (Project Settings > Debug > GDScript, e.g. untyped_declaration), then watch the Profiler's frame time."
  ],
  "questions": [
    ...
  ],
  "summary": ...
}

The reply has 8 findings for the 9 flags — one GD-06 finding carries both F6 and F7, because moving the body into _physics_process and using the cached anim rewrite the same lines — and confirms all 9. Its 15 edits: hurt_sound typed as AudioStream; the three Godot 3 constructs rewritten (@onready var anim: AnimatedSprite2D, await get_tree().create_timer(0.6).timeout, randi_range); a stub func _on_hurt_timer_timeout() -> void: added so the connection resolves; hitbox made @onready and typed Area2D; the $Camera2D line removed; _process split into a _process that flips the cached anim and a _physics_process that moves the body; emit_signal("health_changed", health) replaced by health_changed.emit(health); and six edits that type the remaining declarations. The two low findings (GD-12, GD-17) carry edits too. Its questions ask whether a Camera2D is meant to exist in the scene, what the stubbed timer handler should actually do, and where hurt_sound is played.

Fix: a turret that turns without delta, searches the tree every tick and loads on every shot

Two files, enemies/turret.gd (indented with tabs) and enemies/turret.tscn, with no project.godot. _physics_process finds the player with get_tree().get_first_node_in_group("player") and the muzzle with $Barrel/Muzzle on every tick, turns with rotation += turn_speed * signf(...) and no delta, and calls load("res://fx/muzzle_flash.tscn") every time it fires; take_hit calls free(). The free check raises 4 flags (4 medium) (plus the GD-20 context flag); its reading of the verdict is needs_work. This example has no audit before it, so it carries no audit_handoff. The developer’s note: “The turret spins faster on my 144 Hz monitor and the game hitches every time it fires. Fix those. Leave the free() in take_hit exactly as it is for now - the level script checks for the turret on the same frame, and we will change both together.”

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

# file: enemies/turret.gd
extends StaticBody2D

signal destroyed

@export var bullet_scene: PackedScene
@export var fire_rate: float = 1.5
@export var turn_speed: float = 0.05

var hp: int = 5
var _cooldown: float = 0.0


func _physics_process(delta: float) -> void:
	var player := get_tree().get_first_node_in_group("player") as Node2D
	if player == null:
		return
	var wanted := global_position.direction_to(player.global_position).angle()
	rotation += turn_speed * signf(angle_difference(rotation, wanted))
	_cooldown -= delta
	if _cooldown <= 0.0:
		var flash := load("res://fx/muzzle_flash.tscn") as PackedScene
		var muzzle := $Barrel/Muzzle as Marker2D
		_fire(muzzle.global_position, flash)
		_cooldown = fire_rate


func _fire(from: Vector2, flash: PackedScene) -> void:
	var bullet := bullet_scene.instantiate() as Area2D
	bullet.global_position = from
	bullet.rotation = rotation
	get_parent().add_child(bullet)
	var fx := flash.instantiate() as Node2D
	fx.global_position = from
	get_parent().add_child(fx)


func take_hit(damage: int) -> void:
	hp -= damage
	if hp <= 0:
		destroyed.emit()
		free()
# file: enemies/turret.tscn
[gd_scene load_steps=3 format=3 uid="uid://d8turretm0s"]

[ext_resource type="Script" path="res://enemies/turret.gd" id="1_tu"]

[sub_resource type="CircleShape2D" id="CircleShape2D_base"]
radius = 14.0

[node name="Turret" type="StaticBody2D" groups=["enemies"]]
script = ExtResource("1_tu")

[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
shape = SubResource("CircleShape2D_base")

[node name="Barrel" type="Sprite2D" parent="."]

[node name="Muzzle" type="Marker2D" parent="Barrel"]
position = Vector2(18, 0)

Its facts, decoded, cut to files, flags and verdict_candidate (the full object also lists the project counts, the one script and the scene node it sits on, the 3 funcs, the destroyed signal, the 2 lookups and the scene’s 4 nodes):

{
  "files": [
    {
      "path": "enemies/turret.gd",
      "kind": "gdscript",
      "line": 2,
      "end_line": 42,
      "indent": "tabs"
    },
    {
      "path": "enemies/turret.tscn",
      "kind": "scene",
      "line": 44,
      "end_line": 61
    }
  ],
  "flags": [
    {
      "uid": "F1",
      "id": "GD-05",
      "sev": "medium",
      "file": "enemies/turret.gd",
      "line": 15,
      "text": "_physics_process looks up 2 nodes (get_first_node_in_group(\"player\"), $Barrel/Muzzle) every frame",
      "detail": "A tree search (find_child, get_nodes_in_group) walks the scene tree every frame and allocates an array each time; at 60 frames a second that is wasted work on the main thread and a hitch source as the scene grows. Cache the node once: @onready var sprite: Sprite2D = $Sprite2D (a group: cache it in _ready and refresh on node_added / a signal).",
      "lines": [
        15,
        23
      ]
    },
    {
      "uid": "F2",
      "id": "GD-07",
      "sev": "medium",
      "file": "enemies/turret.gd",
      "line": 19,
      "text": "_physics_process moves or turns the node by a fixed step per frame, without delta",
      "detail": "The step is applied once per call, so the speed depends on the frame rate: twice as fast at 120 fps as at 60, and it stutters when frames drop. Multiply the step by delta (position += velocity * delta), or set velocity and call move_and_slide() on a CharacterBody."
    },
    {
      "uid": "F3",
      "id": "GD-14",
      "sev": "medium",
      "file": "enemies/turret.gd",
      "line": 22,
      "text": "_physics_process calls load() on the per-frame path",
      "detail": "load() resolves the path through the resource cache (or reads the disk) each time this line runs, on the main thread, inside a callback that runs every physics tick or frame. preload() the resource once into a const (or load it once in _ready). instantiate() builds and allocates a whole node tree each time; for something spawned often (bullets, effects, pickups) keep a pool of instances and reuse them instead."
    },
    {
      "uid": "F4",
      "id": "GD-13",
      "sev": "medium",
      "file": "enemies/turret.gd",
      "line": 42,
      "text": "free() deletes this node immediately",
      "detail": "free() destroys the node on the spot, even in the middle of a physics step or of a signal emission that still holds it: anything that touches it later in the frame (another handler, a queued call, a collision callback) hits a freed object, and Godot reports \"Attempt to call on a previously freed instance\" or crashes. Use queue_free(), which frees at the end of the frame."
    },
    {
      "uid": "F5",
      "id": "GD-20",
      "sev": "info",
      "file": "enemies/turret.gd",
      "line": 2,
      "text": "context: 1 script, 3 func(s), 1 signal(s) declared, 0 connect() call(s), 2 node lookup(s), 1 scene(s), Godot version unknown (paste project.godot)",
      "detail": "Also read: enemies/turret.tscn (scene). Only what is pasted is checked: scripts that extend or call code outside the paste, scenes that were not pasted and signals emitted elsewhere are outside the check."
    }
  ],
  "verdict_candidate": "needs_work"
}

The request body (Idempotency-Key: scriptward:fix:b0fdeb55-18f5:a1):

{
  "task": "fix",
  "code": "# file: enemies/turret.gd\nextends StaticBody2D\n\nsignal destroyed\n\n@export var bullet_scene: PackedScene\n@export var fire_rate: float = 1.5\n@export var turn_speed: float = 0.05\n\nvar hp: int = 5\nvar _cooldown: float = 0.0\n...",
  "facts": "{\"project\":{\"godot_version\":null,\"scripts\":1,\"scenes\":1,\"functions\":3,\"signals\":1,\"connections\":0,\"node_lookups\":2,\"autoloads\":[]},\"files\":[{\"path\":\"enemies/turret.gd\",\"kind\":\"gdscript\",\"lin...",
  "note": "The turret spins faster on my 144 Hz monitor and the game hitches every time it fires. Fix those. Leave the free() in take_hit exactly as it is for now - the level script checks for the turret on the same frame, and we will change both together."
}

The reply (output.output), an excerpt: the verdict, the headline, the middle of the one returned script, the change that answers “spins faster on my 144 Hz monitor”, the last verification step and the open item:

{
  "task": "fix",
  "verdict": "partial",
  "headline": "Cached the turret's per-frame node lookups, scaled the turn by delta, and preloaded the muzzle flash so firing no longer stalls the frame.",
  "files": [
    {
      "path": "enemies/turret.gd",
      "content": "...\nconst MUZZLE_FLASH_SCENE: PackedScene = preload(\"res://fx/muzzle_flash.tscn\")\n\nvar hp: int = 5\nvar _cooldown: float = 0.0\n\n@onready var _muzzle: Marker2D = $Barrel/Muzzle\n@onready var _player: Node2D = get_tree().get_first_node_in_group(\"player\") as Node2D\n\n\nfunc _physics_process(delta: float) -> void:\n\tif not is_instance_valid(_player):\n\t\treturn\n\tvar wanted := global_position.direction_to(_player.global_position).angle()\n\trotation += turn_speed * signf(angle_difference(rotation, wanted)) * delta\n\t_cooldown -= delta\n\tif _cooldown <= 0.0:\n\t\t_fire(_muzzle.global_position, MUZZLE_FLASH_SCENE)\n\t\t_cooldown = fire_rate\n..."
    }
  ],
  "changes": [
    ...,
    {
      "file": "enemies/turret.gd",
      "function": "_physics_process",
      "flags": [
        "F2"
      ],
      "change": "Multiplied the rotation step by delta: rotation += turn_speed * signf(angle_difference(rotation, wanted)) * delta.",
      "why": "Without delta the step was applied once per physics call regardless of elapsed time, so the turret turned faster whenever _physics_process ran more often, matching the reported 144 Hz spin-up."
    },
    ...
  ],
  "verify": [
    ...,
    "Watch the Profiler's frame time while the turret fires repeatedly and confirm the per-shot spike that used to come from load() is gone."
  ],
  "open": [
    {
      "item": "take_hit() still calls free() instead of queue_free(), so the turret is destroyed immediately rather than at the end of the frame.",
      "flags": [
        "F4"
      ],
      "reason": "Left exactly as requested - the level script checks for this turret on the same frame free() runs, and that script is outside this paste, so switching to queue_free() needs to be done together with that check."
    }
  ],
  "questions": [
    ...
  ],
  "summary": ...
}

The other three changes cache $Barrel/Muzzle and the player group lookup in @onready vars (F1, function (member)), guard the cached player with is_instance_valid(_player) (flags []: nothing the page flagged), and replace the per-shot load() with a preloaded const MUZZLE_FLASH_SCENE passed into _fire() (F3). The verdict is partial because the note asked to leave free() in take_hit, so the medium F4 is named in open rather than fixed. One of its two questions matters before you ship: with delta applied, turn_speed (0.05) now means radians per second rather than per physics tick, so the turret turns much slower than before unless it is retuned.

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 applies every edit or returned script 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 /gdkit.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 player-check.json player-reply.txt audit > player-edited.txt   (gdkit.js, recon.js next to it)
const fs = require("fs");
const Recon = require("./recon.js");                                            // loads ./gdkit.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 player audit reply it reports (no flag is still raised once the 15 edits are in):

ok: The areas table rates syntax, lifecycle, signals and resources once each.
ok: Every one of the 8 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 9 flags is answered once.
ok: All 15 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 func (with its parameters) and every signal of the paste is still there after the edits.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 8 statements checked

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

ok: 1 file returned whole.
ok: With the fixed files in place the free check raises no high or medium flag beyond the ones left open (0 low).
info: F4 left open by the reply.
ok: Every one of the 3 funcs is still there.
ok: Every one of the 1 signals is still declared.
ok: Every one of the 4 changes names a returned file, a function in the fixed code and real page flags.
ok: The verdict (partial) fits the open list.
ok: The reply repeats no credential from the paste.
0 disagreement(s) in 7 statements checked
still raised: GD-13 medium enemies/turret.gd:44 free() deletes this node immediately

The page does not run the code and is not the Godot editor. After that, open the project in Godot 4 and run the scene (F6) with the Debugger’s Errors tab open and the GDScript warnings on (Project Settings > Debug > GDScript, for example untyped_declaration), watch the Profiler’s frame time, and do the checks the reply’s plan or verify names, 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":"scriptward"}' | 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 player example from player-check.json; step 6 reuses makeInput for the turret (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.

# player-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 "Porting the player from our Godot 3 prototype. It will not even open in 4.3 - what do we fix first?" \
  '{task: "audit", code, facts: (.facts | tojson), note: $note}' player-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 scriptward:<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 scriptward:audit:a1de78f0-27ad:a1 (player audit) and scriptward:fix:b0fdeb55-18f5:a1 (turret fix); the fix run of the player audit handed on (above) is scriptward:fix:12f96ec6-25d8: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="scriptward: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 turret 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 turret-check.json: makeInput then carries its audit_handoff.

# the fix worked example: turret-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 turret spins faster on my 144 Hz monitor and the game hitches every time it fires. Fix those. Leave the free() in take_hit exactly as it is for now - the level script checks for the turret on the same frame, and we will change both together." \
  '{task: "fix", code, facts: (.facts | tojson), note: $note} + (if .audit_handoff then {audit_handoff} else {} end)' \
  turret-check.json)
FKEY="scriptward: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 Scriptward in the browser: the check, the flags, the node-lookup, connection, signal and script inventories, putting an edit or the fixed scripts back into the paste, and the Markdown and CSV exports are free.