← Form Lens
API
Tokens

Run Form Lens from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page scans the Dart - every call matched to its closing bracket, its named arguments read at their own depth - , each class and build method located - for the Form's host and key, every field's validator, the submit's use of validate() and the input basics, then hands it all to the run as facts. The run writes the review or the rewrite; the page then holds it to its own facts and re-scans a rewritten form with the same scanner (formreply.js).

Base URL and the response shape

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

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

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

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a required key (task, form, code, facts) is missing. The page always sends all five of task, form, code, carry, facts; carry is an empty string for review.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. Retry with the same Idempotency-Key so a retry never double-bills.

The task field comes first

Two lanes, chosen by task, in the order the work runs - inspect, then produce:

A missing or unknown task is answered as review. Both lanes send the same five fields. The facts are the page's scan, one finding per line with its id, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the review's decisions into carry for the rewrite lane. The review sends at most 24,000 characters of code (the middle of a longer file is cut on whole lines and marked; every finding is still in the facts); the rewrite lane takes at most 16,000.

The input

FieldRequiredWhat
taskyesreview or rewrite. If it is missing or neither, do review and set "task": "review".
formyesthe form's name, the platforms it targets, and the widget class that holds the Form.
codeyesthe Flutter code of the form. A line starting "// [... N lines cut by the page" marks code the page scanned but did not send.
carrysend it (empty for review)rewrite task only: the review's decisions, one per line as id | confirmed or cleared or extra | where | fix or reason; may be empty.
factsyesthe page's scan, one finding per line as id | area | severity | line | where | evidence | source, what is present, the submit button and the validator messages it found. A line starting "must:" applies to your reply.
retry_notenooptional; if present, your previous reply was unusable - follow it.

The rules the model applies

From the source skill, @flutter/flutter-building-forms - the only standard the run applies:

House rules

The replies

review

Confirm or clear each of the page's findings, add what a scan cannot see, and put the fixes in order.

  1. summary - one sentence, at most 40 words: what a user of this form meets today, and the first fix.
  2. findings - one object per page finding you confirm, in id order: id (the page's id), widget (where it is, e.g. "TextFormField - the email field"), issue (one sentence: what goes wrong for the person filling the form - it never submits, it submits a blank address, it loses what was typed), fix (one sentence: the change), code (the corrected Dart for that piece only, at most 6 lines; "" when fix says it all).
  3. cleared - one object per page finding that is not a problem in this code, with id and why (one sentence tied to the code). Every page id appears exactly once, in findings or in cleared.
  4. extra - at most 5 issues the scan cannot see, each tied to a line of the code: id (X1, X2, ...), area (architecture, validation, submit, input or lifecycle), line (a number), widget, issue, fix. Examples: a validator whose rule does not fit its field (an email checked only for "@"), a submit that runs twice on a double tap, no success indicator after a valid submit, a value read from the controller before validate(). [] when there are none.
  5. plan - 2 to 10 short strings, the fixes in the order to make them (what stops the form working first - the key, then validate(), then the validators, then input basics), each naming the ids it covers (e.g. "Move the GlobalKey out of build into a final field (F1)"). Every confirmed id appears in a step. [] only when nothing is confirmed.
  6. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "review",
  "summary": "one sentence",
  "findings": [ { "id": "F1", "widget": "GlobalKey<FormState> - the form key", "issue": "one sentence", "fix": "one sentence", "code": "final _formKey = GlobalKey<FormState>();" } ],
  "cleared": [ { "id": "F4", "why": "one sentence" } ],
  "extra": [ { "id": "X1", "area": "validation", "line": 18, "widget": "TextFormField - the email field", "issue": "one sentence", "fix": "one sentence" } ],
  "plan": [ "Move the GlobalKey out of build into a final field (F1)" ],
  "caveats": [ ]
}

The page holds it to its own facts (check() in formreply.js; any warning makes the reply flagged): the reply answers the task it was sent; every page finding id appears exactly once across findings and cleared, and no id the page never found; a cleared id has a reason; a confirmed id has an issue and a fix; at most five extra issues, each on a line inside the paste; a fix order of 2 to 10 steps that names every confirmed id; a summary; no web link in the prose.

rewrite

Rewrite the file with the fixes applied.

  1. summary - one sentence, at most 40 words: what the rewrite changes and what it leaves for the team.
  2. code - the complete rewritten file. Keep every import, class, constructor parameter, callback and behaviour, and keep every validator message as worded; change only what the findings name. Never shorten the file with "..." or "unchanged" - write every line. If the code holds a line starting "// [... N lines cut by the page", keep that line exactly where it is. A Form in a StatelessWidget - make the widget a StatefulWidget with a State class (keep the widget's name and constructor), and move the key, the controllers and build into the State. A key made in build, or not final - final _formKey = GlobalKey<FormState>(); as a field of the State class, and key: _formKey on the Form. A TextField in the Form - a TextFormField with the same controller and decoration, and a validator. A missing validator - one that returns a short message for an empty value (in the style of the form's messages) and null otherwise. A validator that returns '' - return null on success instead. A validator with no empty check - check value == null || value.trim().isEmpty first. validate() missing or ignored - if (_formKey.currentState!.validate()) { the existing submission }. A visible password - obscureText: true. A missing keyboard - the keyboardType the finding names. A controller never disposed - a dispose() override that disposes every controller, then super.dispose().
  3. changes - one object per page finding you fixed, in id order: id, change (one sentence: what changed, naming the widget). Extra ids (X1, ...) from the carried review may appear too.
  4. kept - one object per page finding you did not change: id, why (one sentence - the review cleared it, or it needs a decision the code cannot make). Every page id appears exactly once, in changes or in kept; an id the carried review cleared belongs in kept.
  5. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "rewrite",
  "summary": "one sentence",
  "code": "the complete rewritten Dart file",
  "changes": [ { "id": "F1", "change": "one sentence" } ],
  "kept": [ { "id": "F4", "why": "one sentence" } ],
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; every page finding id appears exactly once across changes and kept; every widget class of the paste survives; the file is not shortened (at least 70% of its lines) and elides nothing; and the rewritten code is scanned again - a rule the changes claim fixed that still fires, or a rule the paste never had, is flagged. A validator message the rewrite drops is noted.

Worked examples

Three runs from the page's free samples (invented apps): the review of a sign-in form that can never pass, the rewrite of the same form carrying that review, and the review of a checkout address form that skips validation. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the code, the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.

review - A sign-in form that can never pass

The page read it as: Validation is broken - 2 blocker, 3 major, 1 minor; findings F1 key-in-build, F2 controller-in-build, F3 form-stateless, F4 keyboard-type, F5 validator-empty-string, F6 password-visible.

Input (facts is 1691 characters; the first 1390 are shown):

{
 "task": "review",
 "form": "Form: Sign in\nTargets: Android and iOS\nForm widget: SignInForm",
 "code": "import 'package:flutter/material.dart';\n\nclass SignInForm extends StatelessWidget {\n  const SignInForm({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    final formKey = GlobalKey<FormState>();\n    final email = TextEditingController();\n    return Form(\n      key: formKey,\n      child: Column(\n        children: [\n          TextFormField(\n            controller: email,\n            decoration: const InputDecoration(labelText: 'Email'),\n            validator: (value) {\n              if (value == null || !value.contains('@')) {\n                return 'Enter a valid email';\n              }\n              return '';\n            },\n          ),\n          TextFormField(\n            decoration: const InputDecoration(labelText: 'Password'),\n            validator: (value) => value == null || value.length < 8 ? 'Use at least 8 characters' : null,\n          ),\n          FilledButton(\n            onPressed: () {\n              if (formKey.currentState!.validate()) {\n                ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Signing in')));\n              }\n            },\n            child: const Text('Sign in'),\n          ),\n        ],\n      ),\n    );\n  }\n}\n",
 "carry": "",
 "facts": "The page's scan of 41 lines (14 calls); verdict: Validation is broken - 2 blocker, 3 major, 1 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- F1 | architecture | blocker | line 8 | - | GlobalKey<FormState>() is created inside build, so every rebuild makes a new key and the form loses its state - typed text and errors vanish. | source: final formKey = GlobalKey<FormState>();\n- F2 | lifecycle | major | line 9 | - | TextEditingController email is created inside build, so each rebuild makes a new one and the typed text is lost. | source: final email = TextEditingController();\n- F3 | architecture | major | line 10 | Form | The Form is built in SignInForm, a StatelessWidget - the skill hosts a Form in a StatefulWidget so its key and field state survive rebuilds. | source: return Form(\n- F4 | input | minor | line 14 | TextFormField | The Email field opens the default keyboard - it has no keyboardType. | source: TextFormField(\n- F5 | validation | blocker | line 17 | TextFormField | The validator returns an empty string - any String, even '', counts as an error, so validate() is never true. | source: validator: (value) {\n- F6 | input | major | line 24 | TextFormField | The Password field shows what is typed - it has no obscureText: true. | source: TextFormField(\nPresent: 1 Form, 2 form fields, 2 validators, 1 TextEditingController.\nThe form widget:\n\n[... 301 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "A user can never sign in today: the email validator returns an empty string on success, so validate() is always false, and the key and controller are rebuilt inside build; first move the form into a StatefulWidget with a final key.",
 "findings": [
  {
   "id": "F1",
   "widget": "GlobalKey<FormState> - the form key",
   "issue": "A new key is made on every rebuild, so the person's typed text and the error messages under the fields vanish whenever the widget rebuilds.",
   "fix": "Make the key once as a final field of the new State class and bind it with key: _formKey on the Form.",
   "code": "final _formKey = GlobalKey<FormState>();"
  },
  {
   "id": "F2",
   "widget": "TextEditingController - the email controller",
   "issue": "A fresh controller is made on every rebuild, so the address the user typed is dropped and the old controllers are never disposed.",
   "fix": "Make the controller once as a final field of the State class and dispose it in a dispose() override before super.dispose().",
   "code": "final _email = TextEditingController();\n\n@override\nvoid dispose() {\n  _email.dispose();\n  super.dispose();\n}"
  },
  {
   "id": "F3",
   "widget": "SignInForm - the StatelessWidget that builds the Form",
   "issue": "A StatelessWidget has nowhere to keep the key and the controller, so the form state cannot survive a rebuild.",
   "fix": "Turn SignInForm into a StatefulWidget with a State class that holds the key, the controller and build, keeping the const constructor.",
   "code": "class SignInForm extends StatefulWidget {\n  const SignInForm({super.key});\n\n  @override\n  State<SignInForm> createState() => _SignInFormState();\n}"
  },
  {
   "id": "F4",
   "widget": "TextFormField - the email field",
   "issue": "The user gets the default keyboard and has to hunt for the @ key while typing an address.",
   "fix": "Add keyboardType: TextInputType.emailAddress to the email TextFormField.",
   "code": "keyboardType: TextInputType.emailAddress,"
  },
  {
   "id": "F5",
   "widget": "TextFormField - the email validator",
   "issue": "On a valid address the validator returns an empty String, which counts as an error, so validate() is never true and the form never signs in.",
   "fix": "Return null instead of the empty String when the address passes.",
   "code": "return null;"
  },
  {
   "id": "F6",
   "widget": "TextFormField - the password field",
   "issue": "The password is shown in plain text as the person types it, so anyone nearby can read it.",
   "fix": "Add obscureText: true to the password TextFormField.",
   "code": "obscureText: true,"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "validation",
   "line": 18,
   "widget": "TextFormField - the email validator",
   "issue": "The rule only looks for an @, so an entry such as a lone @ or an address with spaces passes as valid.",
   "fix": "Check value == null || value.trim().isEmpty first, then require some text before and after the @ and a dot in the part after it."
  },
  {
   "id": "X2",
   "area": "validation",
   "line": 26,
   "widget": "TextFormField - the password validator",
   "issue": "Eight spaces pass the length check, so a blank-looking password is accepted.",
   "fix": "Return 'Enter a password' when value.trim().isEmpty before the length check."
  },
  {
   "id": "X3",
   "area": "submit",
   "line": 31,
   "widget": "FilledButton - the Sign in button",
   "issue": "A valid submit only shows the Signing in SnackBar; nothing reads the email or password or hands them to a sign-in call.",
   "fix": "Inside the validate() branch, pass the controller text and the password to the app's sign-in callback, then show the SnackBar."
  },
  {
   "id": "X4",
   "area": "submit",
   "line": 29,
   "widget": "FilledButton - the Sign in button",
   "issue": "A double tap runs the submit twice and stacks two SnackBars, and would send two sign-in requests once one is wired.",
   "fix": "Keep a submitting flag in the State class and set onPressed to null while a sign-in is in progress."
  }
 ],
 "plan": [
  "Turn SignInForm into a StatefulWidget with a State class (F3)",
  "Move the GlobalKey out of build into a final field bound to the Form (F1)",
  "Move the email TextEditingController into the State class and dispose it (F2)",
  "Return null from the email validator on success so validate() can pass (F5)",
  "Tighten the email and password validators with empty checks (X1, X2)",
  "Wire the valid submit to a real sign-in and guard against double taps (X3, X4)",
  "Hide the password with obscureText: true and give the email field its keyboard (F6, F4)"
 ],
 "caveats": [
  "The code holds no sign-in callback, so X3 needs the team to name where the credentials go.",
  "The password field has no controller, so the submit needs one or an onSaved to read the value."
 ]
}

What step 5 prints for it: F1: Make the key once as a final field of the new State class and bind it with key: _formKey on the Form. | F2: Make the controller once as a final field of the State class and dispose it in a dispose() override before super.dispose(). | F3: Turn SignInForm into a StatefulWidget with a State class that holds the key, the controller and build, keeping the const constructor. | F4: Add keyboardType: TextInputType.emailAddress to the email TextFormField. | F5: Return null instead of the empty String when the address passes. | F6: Add obscureText: true to the password TextFormField.

The page's check: kept - nothing to flag

rewrite - A sign-in form that can never pass

The input carries the saved review (R.laneForm fills carry from the review reply), as the page's handoff does. The page re-scanned the rewritten code: 6 findings before, 0 after.

Input (facts is 1691 characters; the first 1390 are shown; carry is 1578 characters; the first 1395 are shown):

{
 "task": "rewrite",
 "form": "Form: Sign in\nTargets: Android and iOS\nForm widget: SignInForm",
 "code": "import 'package:flutter/material.dart';\n\nclass SignInForm extends StatelessWidget {\n  const SignInForm({super.key});\n\n  @override\n  Widget build(BuildContext context) {\n    final formKey = GlobalKey<FormState>();\n    final email = TextEditingController();\n    return Form(\n      key: formKey,\n      child: Column(\n        children: [\n          TextFormField(\n            controller: email,\n            decoration: const InputDecoration(labelText: 'Email'),\n            validator: (value) {\n              if (value == null || !value.contains('@')) {\n                return 'Enter a valid email';\n              }\n              return '';\n            },\n          ),\n          TextFormField(\n            decoration: const InputDecoration(labelText: 'Password'),\n            validator: (value) => value == null || value.length < 8 ? 'Use at least 8 characters' : null,\n          ),\n          FilledButton(\n            onPressed: () {\n              if (formKey.currentState!.validate()) {\n                ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Signing in')));\n              }\n            },\n            child: const Text('Sign in'),\n          ),\n        ],\n      ),\n    );\n  }\n}\n",
 "carry": "F1 | confirmed | GlobalKey<FormState> - the form key | Make the key once as a final field of the new State class and bind it with key: _formKey on the Form.\nF2 | confirmed | TextEditingController - the email controller | Make the controller once as a final field of the State class and dispose it in a dispose() override before super.dispose().\nF3 | confirmed | SignInForm - the StatelessWidget that builds the Form | Turn SignInForm into a StatefulWidget with a State class that holds the key, the controller and build, keeping the const constructor.\nF4 | confirmed | TextFormField - the email field | Add keyboardType: TextInputType.emailAddress to the email TextFormField.\nF5 | confirmed | TextFormField - the email validator | Return null instead of the empty String when the address passes.\nF6 | confirmed | TextFormField - the password field | Add obscureText: true to the password TextFormField.\nX1 | extra | TextFormField - the email validator (line 18) | Check value == null || value.trim().isEmpty first, then require some text before and after the @ and a dot in the part after it.\nX2 | extra | TextFormField - the password validator (line 26) | Return 'Enter a password' when value.trim().isEmpty before the length check.\nX3 | extra | FilledButton - the Sign in button (line 31) | Inside the validate() branch, pass the controller text and the password to the app's sign-in callback,\n\n[... 183 more characters cut from this display ...]",
 "facts": "The page's scan of 41 lines (14 calls); verdict: Validation is broken - 2 blocker, 3 major, 1 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- F1 | architecture | blocker | line 8 | - | GlobalKey<FormState>() is created inside build, so every rebuild makes a new key and the form loses its state - typed text and errors vanish. | source: final formKey = GlobalKey<FormState>();\n- F2 | lifecycle | major | line 9 | - | TextEditingController email is created inside build, so each rebuild makes a new one and the typed text is lost. | source: final email = TextEditingController();\n- F3 | architecture | major | line 10 | Form | The Form is built in SignInForm, a StatelessWidget - the skill hosts a Form in a StatefulWidget so its key and field state survive rebuilds. | source: return Form(\n- F4 | input | minor | line 14 | TextFormField | The Email field opens the default keyboard - it has no keyboardType. | source: TextFormField(\n- F5 | validation | blocker | line 17 | TextFormField | The validator returns an empty string - any String, even '', counts as an error, so validate() is never true. | source: validator: (value) {\n- F6 | input | major | line 24 | TextFormField | The Password field shows what is typed - it has no obscureText: true. | source: TextFormField(\nPresent: 1 Form, 2 form fields, 2 validators, 1 TextEditingController.\nThe form widget:\n\n[... 301 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "rewrite",
 "summary": "SignInForm becomes a StatefulWidget whose State holds one final form key and a disposed email controller, the email validator returns null on success, and the fields get their keyboard and hidden password; the sign-in call is left for the team.",
 "code": "import 'package:flutter/material.dart';\n\nclass SignInForm extends StatefulWidget {\n  const SignInForm({super.key});\n\n  @override\n  State<SignInForm> createState() => _SignInFormState();\n}\n\nclass _SignInFormState extends State<SignInForm> {\n  final _formKey = GlobalKey<FormState>();\n  final _email = [... 1577 more characters cut from this display]",
 "changes": [
  {
   "id": "F1",
   "change": "The GlobalKey<FormState> is now made once as final _formKey in _SignInFormState and bound with key: _formKey on the Form."
  },
  {
   "id": "F2",
   "change": "The email TextEditingController is now the final field _email of the State class and is disposed in a dispose() override before super.dispose()."
  },
  {
   "id": "F3",
   "change": "SignInForm is now a StatefulWidget with the same const constructor, and _SignInFormState holds the key, the controller and build."
  },
  {
   "id": "F4",
   "change": "The email TextFormField now sets keyboardType: TextInputType.emailAddress."
  },
  {
   "id": "F5",
   "change": "The email validator in the TextFormField now returns null when the address passes instead of the empty String."
  },
  {
   "id": "F6",
   "change": "The password TextFormField now sets obscureText: true."
  },
  {
   "id": "X1",
   "change": "The email validator checks for an empty value first, then requires text before the @ and a dot after it, still returning 'Enter a valid email'."
  },
  {
   "id": "X2",
   "change": "The password validator returns the new message 'Enter a password' for an empty value before the length check."
  }
 ],
 "kept": [],
 "caveats": [
  "X3 is not applied: SignInForm has no sign-in callback to pass the email and password to, so the team must add one and call it inside the validate() branch.",
  "X4 is not applied: the button only shows a SnackBar today, so a submitting flag waits for a real asynchronous sign-in call.",
  "'Enter a password' is a new message written in the style of the existing ones."
 ]
}

Cut from this display only (the saved reply and the check use the full text): code (1876 characters, 299 shown).

What step 5 prints for it: F1: The GlobalKey<FormState> is now made once as final _formKey in _SignInFormState and bound with key: _formKey on the Form. | F2: The email TextEditingController is now the final field _email of the State class and is disposed in a dispose() override before super.dispose(). | F3: SignInForm is now a StatefulWidget with the same const constructor, and _SignInFormState holds the key, the controller and build. | F4: The email TextFormField now sets keyboardType: TextInputType.emailAddress. | F5: The email validator in the TextFormField now returns null when the address passes instead of the empty String. | F6: The password TextFormField now sets obscureText: true. | X1: The email validator checks for an empty value first, then requires text before the @ and a dot after it, still returning 'Enter a valid email'. | X2: The password validator returns the new message 'Enter a password' for an empty value before the length check.

The page's check: kept - nothing to flag

review - A checkout address form that skips validation

The page read it as: Fix before release - 0 blocker, 6 major, 1 minor; findings F1 key-not-final, F2 controller-not-disposed, F3 controller-not-disposed, F4 controller-not-disposed, F5 textfield-in-form, F6 field-no-validator, F7 validate-ignored.

Input (code is 1456 characters; the first 1398 are shown; facts is 1747 characters; the first 1398 are shown):

{
 "task": "review",
 "form": "Form: Shipping address\nTargets: Android and iOS\nForm widget: AddressForm",
 "code": "import 'package:flutter/material.dart';\n\nclass AddressForm extends StatefulWidget {\n  const AddressForm({super.key, required this.onSaved});\n  final void Function(String name, String street, String zip) onSaved;\n\n  @override\n  State<AddressForm> createState() => _AddressFormState();\n}\n\nclass _AddressFormState extends State<AddressForm> {\n  GlobalKey<FormState> _formKey = GlobalKey<FormState>();\n  final _name = TextEditingController();\n  final _street = TextEditingController();\n  final _zip = TextEditingController();\n\n  @override\n  Widget build(BuildContext context) {\n    return Form(\n      key: _formKey,\n      child: Column(\n        children: [\n          TextFormField(\n            controller: _name,\n            decoration: const InputDecoration(labelText: 'Full name'),\n            validator: (value) => value == null || value.trim().isEmpty ? 'Enter your name' : null,\n          ),\n          TextField(\n            controller: _street,\n            decoration: const InputDecoration(labelText: 'Street address'),\n          ),\n          TextFormField(\n            controller: _zip,\n            decoration: const InputDecoration(labelText: 'Postcode'),\n          ),\n          ElevatedButton(\n            onPressed: () {\n              _formKey.currentState!.validate();\n              widget.onSaved(_name.text, _street.text, _zip.text);\n            },\n            child: const Text('Use this\n\n[... 58 more characters cut from this display ...]",
 "carry": "",
 "facts": "The page's scan of 48 lines (16 calls); verdict: Fix before release - 0 blocker, 6 major, 1 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- F1 | architecture | minor | line 12 | - | The GlobalKey<FormState> field is not final, so it can be replaced and the form's state lost. | source: GlobalKey<FormState> _formKey = GlobalKey<FormState>();\n- F2 | lifecycle | major | line 13 | - | TextEditingController _name is never disposed, so it leaks when the form closes. | source: final _name = TextEditingController();\n- F3 | lifecycle | major | line 14 | - | TextEditingController _street is never disposed, so it leaks when the form closes. | source: final _street = TextEditingController();\n- F4 | lifecycle | major | line 15 | - | TextEditingController _zip is never disposed, so it leaks when the form closes. | source: final _zip = TextEditingController();\n- F5 | validation | major | line 28 | TextField | A plain TextField sits inside the Form - it has no validator, so validate() never checks it. | source: TextField(\n- F6 | validation | major | line 32 | TextFormField | TextFormField has no validator:, so the form accepts anything in it. | source: TextFormField(\n- F7 | submit | major | line 38 | - | validate() is called but its result is ignored, so the submission runs even when a field is invalid. | source: _formKey.currentState!.validate();\nPresent: 1 Form,\n\n[... 349 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "Today a person can tap Use this address with a blank street and postcode and the address is still saved, because the validate() result is ignored; first, submit only when validate() returns true.",
 "findings": [
  {
   "id": "F1",
   "widget": "GlobalKey<FormState> - the form key",
   "issue": "The key field can be reassigned, and a new key would drop what the person has typed into the form.",
   "fix": "Declare the key as a final field of _AddressFormState.",
   "code": "final _formKey = GlobalKey<FormState>();"
  },
  {
   "id": "F2",
   "widget": "TextEditingController - the name controller",
   "issue": "The _name controller is never disposed, so it stays in memory after the person leaves the address screen.",
   "fix": "Add a dispose() override that disposes _name before super.dispose().",
   "code": "@override\nvoid dispose() {\n  _name.dispose();\n  super.dispose();\n}"
  },
  {
   "id": "F3",
   "widget": "TextEditingController - the street controller",
   "issue": "The _street controller is never disposed, so it stays in memory after the person leaves the address screen.",
   "fix": "Dispose _street in the same dispose() override.",
   "code": "_street.dispose();"
  },
  {
   "id": "F4",
   "widget": "TextEditingController - the postcode controller",
   "issue": "The _zip controller is never disposed, so it stays in memory after the person leaves the address screen.",
   "fix": "Dispose _zip in the same dispose() override.",
   "code": "_zip.dispose();"
  },
  {
   "id": "F5",
   "widget": "TextField - the street address field",
   "issue": "The street field is a plain TextField with no validator, so the form saves an address with a blank street.",
   "fix": "Replace it with a TextFormField that keeps the controller and decoration and adds a validator with the new message 'Enter your street address'.",
   "code": "TextFormField(\n  controller: _street,\n  decoration: const InputDecoration(labelText: 'Street address'),\n  validator: (value) => value == null || value.trim().isEmpty ? 'Enter your street address' : null,\n),"
  },
  {
   "id": "F6",
   "widget": "TextFormField - the postcode field",
   "issue": "The postcode field has no validator, so the form accepts a blank postcode.",
   "fix": "Add a validator with the new message 'Enter your postcode' that returns null when a value is present.",
   "code": "validator: (value) => value == null || value.trim().isEmpty ? 'Enter your postcode' : null,"
  },
  {
   "id": "F7",
   "widget": "ElevatedButton - the Use this address button",
   "issue": "The validate() result is thrown away, so onSaved runs and the address is saved even while the name field shows its error.",
   "fix": "Call widget.onSaved only inside an if on _formKey.currentState!.validate().",
   "code": "if (_formKey.currentState!.validate()) {\n  widget.onSaved(_name.text, _street.text, _zip.text);\n}"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "submit",
   "line": 39,
   "widget": "ElevatedButton - the Use this address button",
   "issue": "After a valid submit nothing on screen tells the person the address was accepted.",
   "fix": "After widget.onSaved, show a SnackBar through ScaffoldMessenger.of(context).showSnackBar with a short confirmation."
  },
  {
   "id": "X2",
   "area": "submit",
   "line": 37,
   "widget": "ElevatedButton - the Use this address button",
   "issue": "A double tap calls onSaved twice, so the same address can be saved twice.",
   "fix": "Keep a _submitting flag in the State, set it in setState before calling onSaved, and set onPressed to null while it is true."
  }
 ],
 "plan": [
  "Make the form key a final field of _AddressFormState (F1)",
  "Wrap widget.onSaved in an if on _formKey.currentState!.validate() (F7)",
  "Turn the street TextField into a TextFormField with a validator (F5)",
  "Add a validator to the postcode TextFormField (F6)",
  "Add a dispose() override for _name, _street and _zip (F2, F3, F4)",
  "Show a SnackBar after a valid submit and block a second tap while saving (X1, X2)"
 ],
 "caveats": [
  "The postcode validator only checks for a blank value; a format rule depends on the countries the app ships to."
 ]
}

What step 5 prints for it: F1: Declare the key as a final field of _AddressFormState. | F2: Add a dispose() override that disposes _name before super.dispose(). | F3: Dispose _street in the same dispose() override. | F4: Dispose _zip in the same dispose() override. | F5: Replace it with a TextFormField that keeps the controller and decoration and adds a validator with the new message 'Enter your street address'. | F6: Add a validator with the new message 'Enter your postcode' that returns null when a value is present. | F7: Call widget.onSaved only inside an if on _formKey.currentState!.validate().

The page's check: kept - nothing to flag

Build the input yourself

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

const K = FormKit, R = FormReply;
const f = K.form({ name: "Login", targets: "mobile", carry: "",
  code: "class Login extends StatelessWidget {\n  Widget build(BuildContext c) {\n    final key = GlobalKey<FormState>();\n    return Form(key: key, child: TextFormField());\n  }\n}" });
// f.findings (id, area, sev, line, widget, evidence, fix), f.checklist, f.test (the empty-submit widget test)
// K.findingsCsv(f), K.checklistMd(f) - the free exports
const b = K.buildInput(f, "review");                         // or "rewrite" (R.laneForm(form, "rewrite", review) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "review");
// R.check(reply, b)                                         // {status: kept or flags, notes}

Get a token for the steps below from /tokens.html.

Step 1 — a tiny client helper

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

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

Step 2 — a token

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

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

Step 3 — GET /me

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

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

Step 4 — POST /estimate

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

# input.json: the run input object - {"task": "review", "form": "Form: Sign in
Targets: Android and iOS
Form widget: SignInForm", "code": "import 'package:flutter/material.dart';
...", "carry": "", "facts": "The page's scan of 40 lines ...
Findings (id | area | severity | line | where | evidence | source):
- F1 | architecture | blocker | line 8 | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}' 

Step 5 — POST /run, then poll the job

Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses form-lens:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A review reply carries summary, findings, cleared, extra, plan and caveats; a rewrite reply carries summary, code, changes, kept and caveats. The code below prints the gist of either: each confirmed finding and its fix, or each change.

KEY="form-lens:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "rewrite" then (.changes[] | "\(.id): \(.change)") 
       else (.findings[] | "\(.id): \(.fix)") end' reply.json

Step 6 — POST /run-stream

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

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