← Channel Lens
API
Tokens

Run Channel Lens from your own code

Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page splits the paste at its file lines, masks comments and strings in each file, resolves string constants, and lines the sides up - the channel name on every side, every method Dart invokes against every branch each handler matches, the plugin classes and their lifecycle, and pubspec's pluginClass, 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 the rewritten files with the same scanner (channelreply.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":"channel-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, unit, code, facts) is missing. The page always sends all five of task, unit, 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".
unityesthe plugin's name, whether it is an existing plugin or a new one being set up, and the files pasted.
codeyesthe files, one after another, each starting with a line "// file: path" (YAML: "# file: path"). A line starting "// [... N lines cut by the page" marks code the page scanned but did not send. Line numbers count from the top of the whole paste.
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; the method contract, one method per line with where Dart invokes it, where each platform handles it, and its status; and the channels and classes on each side. 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-plugins - 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 an app using this plugin gets today (often MissingPluginException, a Future that never completes, or a build failure), and the first fix.
  2. findings - one object per page finding you confirm, in id order: id (the page's id), widget (where it sits - a file and class, method or pubspec key, e.g. "BatteryPlugin.kt - onMethodCall" or "pubspec.yaml - ios.pluginClass"), issue (one sentence: what the app sees), fix (one sentence: the change), code (the corrected code for that piece only, at most 6 lines, in that file's language; "" 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 paste: id (X1, X2, ...), area (channel, methods, embedding, lifecycle, pubspec, dart or android), line (a number), widget, issue, fix. Examples: an argument read with a type Dart does not send, a result answered twice or on a background thread, an exception that never becomes result.error / a PlatformException, an Activity used after detach, a public member with no documentation. [] when there are none.
  5. plan - 2 to 10 short strings, the fixes in the order to make them (whatever stops the build or the registration first, whatever its area, then the channel name, then methods and fallbacks, then lifecycle, then the rest), each naming the ids it covers (e.g. "Open the Android channel under Dart's name (M4)"). 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": "M1", "widget": "BatteryPlugin.kt - registerWith", "issue": "one sentence", "fix": "one sentence", "code": "val channel = MethodChannel(messenger, \"samples.flutter.dev/battery\")" } ],
  "cleared": [ { "id": "M2", "why": "one sentence" } ],
  "extra": [ { "id": "X1", "area": "methods", "line": 44, "widget": "BatteryPlugin.kt - onMethodCall", "issue": "one sentence", "fix": "one sentence" } ],
  "plan": [ "Open the Android channel under Dart's name (M4)" ],
  "caveats": [ ]
}

The page holds it to its own facts (check() in channelreply.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 files 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 - every pasted file, rewritten, in the same order, each under its own unchanged file line. Keep every import, class, function and pubspec key, with every class name unchanged; change only what the findings name, plus the imports a change needs. Never shorten a file with "..." or "unchanged" - write every line. Never add a file that was not pasted. If the code holds a line starting "// [... N lines cut by the page", keep that line exactly where it is. A channel name that differs - Dart's name on every side. A method Dart invokes that a platform does not handle - a branch for it (answering with the platform call the plugin already makes, or result.error / FlutterError with a clear code when the platform cannot do it), or the spelling fixed on one side. No not-implemented fallback - result.notImplemented() (Android), result(FlutterMethodNotImplemented) (iOS/macOS), result->NotImplemented() (Windows) for unknown methods. Only registerWith, no FlutterPlugin - implement FlutterPlugin with onAttachedToEngine and onDetachedFromEngine and a public constructor; keep registerWith only if the file already has it, and route both through one private setup method. No cleanup on detach - channel.setMethodCallHandler(null) in onDetachedFromEngine. An Activity without ActivityAware - implement ActivityAware with all four callbacks, setting the Activity on attach and clearing it on detach. register(with:) missing, or no addMethodCallDelegate - add them. pluginClass or package that does not match - pubspec's value changed to the class and package the code declares. A Dart invoke not awaited - await it (or return the Future).
  3. changes - one object per page finding you fixed, in id order: id, change (one sentence: what changed, naming the file). 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 file or a decision the paste does not hold). 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": "every file, rewritten, each under its own file line",
  "changes": [ { "id": "M1", "change": "one sentence" } ],
  "kept": [ { "id": "M2", "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 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 file line, class or struct the rewrite drops or renames is flagged.

Worked examples

Three runs from the page's free samples (invented apps): the review of a battery plugin whose calls never arrive, the rewrite of the same files carrying that review, and the review of a share plugin that leaks and hangs on Android. 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 battery plugin whose calls never arrive

The page read it as: Calls never arrive - 3 blocker, 1 major, 0 minor; findings M1 pluginclass-mismatch, M2 method-unhandled, M3 no-flutterplugin, M4 channel-mismatch.

Input (code is 2540 characters; the first 1393 are shown; facts is 2265 characters; the first 1396 are shown):

{
 "task": "review",
 "unit": "Plugin: battery_level\nStage: an existing plugin\nFiles: pubspec.yaml (pubspec.yaml), lib/battery_level.dart (Dart), android/src/main/kotlin/com/example/battery_level/BatteryPlugin.kt (Kotlin/Java), ios/Classes/BatteryLevelPlugin.swift (Swift)",
 "code": "# file: pubspec.yaml\nname: battery_level\nflutter:\n  plugin:\n    platforms:\n      android:\n        package: com.example.battery_level\n        pluginClass: BatteryLevelPlugin\n      ios:\n        pluginClass: BatteryLevelPlugin\n\n// file: lib/battery_level.dart\nimport 'package:flutter/services.dart';\n\nclass BatteryLevel {\n  static const _channel = MethodChannel('samples.flutter.dev/battery');\n\n  static Future<int?> level() => _channel.invokeMethod<int>('getBatteryLevel');\n  static Future<bool?> isCharging() => _channel.invokeMethod<bool>('isCharging');\n}\n\n// file: android/src/main/kotlin/com/example/battery_level/BatteryPlugin.kt\npackage com.example.battery_level\n\nimport android.content.Context\nimport android.os.BatteryManager\nimport io.flutter.plugin.common.MethodCall\nimport io.flutter.plugin.common.MethodChannel\nimport io.flutter.plugin.common.MethodChannel.MethodCallHandler\nimport io.flutter.plugin.common.MethodChannel.Result\nimport io.flutter.plugin.common.PluginRegistry.Registrar\n\nclass BatteryPlugin(private val context: Context) : MethodCallHandler {\n  companion object {\n    @JvmStatic\n    fun registerWith(registrar: Registrar) {\n      val channel = MethodChannel(registrar.messenger(), \"samples.flutter.dev/batteries\")\n      channel.setMethodCallHandler(BatteryPlugin(registrar.context()))\n    }\n  }\n\n  override fun onMethodCall(call: MethodCall, result: Result) {\n    when\n\n[... 1147 more characters cut from this display ...]",
 "carry": "",
 "facts": "The page's scan of 77 lines in 4 files (pubspec.yaml = pubspec.yaml, lines 2-11; lib/battery_level.dart = Dart, lines 13-21; android/src/main/kotlin/com/example/battery_level/BatteryPlugin.kt = Kotlin/Java, lines 23-56; ios/Classes/BatteryLevelPlugin.swift = Swift, lines 58-77); verdict: Calls never arrive - 3 blocker, 1 major, 0 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- M1 | pubspec | blocker | line 8 | pubspec.yaml | pubspec names pluginClass BatteryLevelPlugin for Android, but the pasted plugin class is BatteryPlugin - the generated registrant references a class that does not exist and the build fails. | source: pluginClass: BatteryLevelPlugin\n- M2 | methods | major | line 19 | BatteryLevelPlugin.swift | Dart invokes 'isCharging', which the iOS handler (BatteryLevelPlugin.swift) never matches - it falls to notImplemented, so the call throws MissingPluginException. | source: static Future<bool?> isCharging() => _channel.invokeMethod<bool>('isCharging');\n- M3 | embedding | blocker | line 33 | BatteryPlugin | BatteryPlugin does not implement FlutterPlugin - with only the v1 registerWith entry point, the v2 embedding never registers the plugin and every call throws MissingPluginException. | source: class BatteryPlugin(private val context: Context) : MethodCallHandler {\n- M4 | channel | blocker | line 37 | BatteryPlugin.kt | The Android side\n\n[... 869 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "Today the Android build fails on a missing BatteryLevelPlugin class, and once it builds every Android call and iOS isCharging throw MissingPluginException; first point pubspec's Android pluginClass at BatteryPlugin.",
 "findings": [
  {
   "id": "M1",
   "widget": "pubspec.yaml - android pluginClass",
   "issue": "The generated registrant references an Android class BatteryLevelPlugin that does not exist, so the app fails to build.",
   "fix": "Change the Android pluginClass to BatteryPlugin, keeping the class name and changing pubspec rather than renaming the Kotlin class.",
   "code": "      android:\n        package: com.example.battery_level\n        pluginClass: BatteryPlugin"
  },
  {
   "id": "M2",
   "widget": "BatteryLevelPlugin.swift - handle",
   "issue": "On iOS, BatteryLevel.isCharging() falls through to FlutterMethodNotImplemented and throws MissingPluginException.",
   "fix": "Add an isCharging case that reads UIDevice.current.batteryState and answers true for charging or full.",
   "code": "case \"isCharging\":\n  UIDevice.current.isBatteryMonitoringEnabled = true\n  let state = UIDevice.current.batteryState\n  result(state == .charging || state == .full)"
  },
  {
   "id": "M3",
   "widget": "BatteryPlugin.kt - BatteryPlugin",
   "issue": "With only the v1 registerWith entry point, the v2 embedding never registers the plugin and every Android call throws MissingPluginException.",
   "fix": "Implement FlutterPlugin with a public no-argument constructor, open the channel in onAttachedToEngine through one private setup method that registerWith also calls, and clear the handler with setMethodCallHandler(null) in onDetachedFromEngine.",
   "code": "override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {\n  setup(binding.binaryMessenger, binding.applicationContext)\n}\noverride fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {\n  channel.setMethodCallHandler(null)\n}"
  },
  {
   "id": "M4",
   "widget": "BatteryPlugin.kt - registerWith",
   "issue": "Android listens on samples.flutter.dev/batteries while Dart sends on samples.flutter.dev/battery, so every Android call throws MissingPluginException.",
   "fix": "Open the Android channel under Dart's name, samples.flutter.dev/battery.",
   "code": "val channel = MethodChannel(registrar.messenger(), \"samples.flutter.dev/battery\")"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "methods",
   "line": 71,
   "widget": "BatteryLevelPlugin.swift - handle",
   "issue": "When the battery state is unknown (for example on the simulator) batteryLevel is -1, so Dart receives -100 as a level.",
   "fix": "Answer with FlutterError(code: \"UNAVAILABLE\", message: \"Battery level not available\", details: nil) when batteryLevel is below 0."
  },
  {
   "id": "X2",
   "area": "android",
   "line": 36,
   "widget": "BatteryPlugin.kt - registerWith",
   "issue": "The public registerWith member is not an override and carries no documentation.",
   "fix": "Add a KDoc comment to registerWith stating that it is the legacy v1 entry point."
  }
 ],
 "plan": [
  "Point the Android pluginClass in pubspec.yaml at BatteryPlugin so the registrant builds (M1)",
  "Implement FlutterPlugin with a public constructor and one private setup method shared with registerWith (M3)",
  "Open the Android channel under Dart's name samples.flutter.dev/battery (M4)",
  "Add the isCharging case to the iOS handler (M2)",
  "Return a FlutterError for an unknown iOS battery level (X1)",
  "Document registerWith (X2)"
 ],
 "caveats": [
  "The example app's MainActivity was not pasted, so whether it extends FlutterActivity is unchecked."
 ]
}

What step 5 prints for it: M1: Change the Android pluginClass to BatteryPlugin, keeping the class name and changing pubspec rather than renaming the Kotlin class. | M2: Add an isCharging case that reads UIDevice.current.batteryState and answers true for charging or full. | M3: Implement FlutterPlugin with a public no-argument constructor, open the channel in onAttachedToEngine through one private setup method that registerWith also calls, and clear the handler with setMethodCallHandler(null) in onDetachedFromEngine. | M4: Open the Android channel under Dart's name, samples.flutter.dev/battery.

The page's check: kept - nothing to flag

rewrite - A battery plugin whose calls never arrive

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: 4 findings before, 0 after.

Input (code is 2540 characters; the first 1393 are shown; facts is 2257 characters; the first 1396 are shown):

{
 "task": "rewrite",
 "unit": "Plugin: battery_level\nStage: an existing plugin\nFiles: pubspec.yaml (pubspec.yaml), lib/battery_level.dart (Dart), android/src/main/kotlin/com/example/battery_level/BatteryPlugin.kt (Kotlin/Java), ios/Classes/BatteryLevelPlugin.swift (Swift)",
 "code": "# file: pubspec.yaml\nname: battery_level\nflutter:\n  plugin:\n    platforms:\n      android:\n        package: com.example.battery_level\n        pluginClass: BatteryLevelPlugin\n      ios:\n        pluginClass: BatteryLevelPlugin\n\n// file: lib/battery_level.dart\nimport 'package:flutter/services.dart';\n\nclass BatteryLevel {\n  static const _channel = MethodChannel('samples.flutter.dev/battery');\n\n  static Future<int?> level() => _channel.invokeMethod<int>('getBatteryLevel');\n  static Future<bool?> isCharging() => _channel.invokeMethod<bool>('isCharging');\n}\n\n// file: android/src/main/kotlin/com/example/battery_level/BatteryPlugin.kt\npackage com.example.battery_level\n\nimport android.content.Context\nimport android.os.BatteryManager\nimport io.flutter.plugin.common.MethodCall\nimport io.flutter.plugin.common.MethodChannel\nimport io.flutter.plugin.common.MethodChannel.MethodCallHandler\nimport io.flutter.plugin.common.MethodChannel.Result\nimport io.flutter.plugin.common.PluginRegistry.Registrar\n\nclass BatteryPlugin(private val context: Context) : MethodCallHandler {\n  companion object {\n    @JvmStatic\n    fun registerWith(registrar: Registrar) {\n      val channel = MethodChannel(registrar.messenger(), \"samples.flutter.dev/batteries\")\n      channel.setMethodCallHandler(BatteryPlugin(registrar.context()))\n    }\n  }\n\n  override fun onMethodCall(call: MethodCall, result: Result) {\n    when\n\n[... 1147 more characters cut from this display ...]",
 "carry": "M1 | confirmed | pubspec.yaml - android pluginClass | Change the Android pluginClass to BatteryPlugin, keeping the class name and changing pubspec rather than renaming the Kotlin class.\nM2 | confirmed | BatteryLevelPlugin.swift - handle | Add an isCharging case that reads UIDevice.current.batteryState and answers true for charging or full.\nM3 | confirmed | BatteryPlugin.kt - BatteryPlugin | Implement FlutterPlugin with a public no-argument constructor, open the channel in onAttachedToEngine through one private setup method that registerWith also calls, and clear the handler with setMethodCallHandler(null) in onDetachedFromEngine.\nM4 | confirmed | BatteryPlugin.kt - registerWith | Open the Android channel under Dart's name, samples.flutter.dev/battery.\nX1 | extra | BatteryLevelPlugin.swift - handle (line 71) | Answer with FlutterError(code: \"UNAVAILABLE\", message: \"Battery level not available\", details: nil) when batteryLevel is below 0.\nX2 | extra | BatteryPlugin.kt - registerWith (line 36) | Add a KDoc comment to registerWith stating that it is the legacy v1 entry point.",
 "facts": "The page's scan of 77 lines in 4 files (pubspec.yaml = pubspec.yaml, lines 2-11; lib/battery_level.dart = Dart, lines 13-21; android/src/main/kotlin/com/example/battery_level/BatteryPlugin.kt = Kotlin/Java, lines 23-56; ios/Classes/BatteryLevelPlugin.swift = Swift, lines 58-77); verdict: Calls never arrive - 3 blocker, 1 major, 0 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- M1 | pubspec | blocker | line 8 | pubspec.yaml | pubspec names pluginClass BatteryLevelPlugin for Android, but the pasted plugin class is BatteryPlugin - the generated registrant references a class that does not exist and the build fails. | source: pluginClass: BatteryLevelPlugin\n- M2 | methods | major | line 19 | BatteryLevelPlugin.swift | Dart invokes 'isCharging', which the iOS handler (BatteryLevelPlugin.swift) never matches - it falls to notImplemented, so the call throws MissingPluginException. | source: static Future<bool?> isCharging() => _channel.invokeMethod<bool>('isCharging');\n- M3 | embedding | blocker | line 33 | BatteryPlugin | BatteryPlugin does not implement FlutterPlugin - with only the v1 registerWith entry point, the v2 embedding never registers the plugin and every call throws MissingPluginException. | source: class BatteryPlugin(private val context: Context) : MethodCallHandler {\n- M4 | channel | blocker | line 37 | BatteryPlugin.kt | The Android side\n\n[... 861 more characters cut from this display ...]"
}

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

{
 "task": "rewrite",
 "summary": "Points Android's pluginClass at BatteryPlugin, moves BatteryPlugin onto FlutterPlugin with Dart's channel name, and adds an iOS isCharging case; the team still needs to test both platforms on real devices.",
 "code": "# file: pubspec.yaml\nname: battery_level\nflutter:\n  plugin:\n    platforms:\n      android:\n        package: com.example.battery_level\n        pluginClass: BatteryPlugin\n      ios:\n        pluginClass: BatteryLevelPlugin\n\n// file: lib/battery_level.dart\nimport 'package:flutter/services.dart';\n\nclass BatteryLevel { [... 3322 more characters cut from this display]",
 "changes": [
  {
   "id": "M1",
   "change": "pubspec.yaml now names BatteryPlugin as the Android pluginClass, keeping the Kotlin class name as declared."
  },
  {
   "id": "M2",
   "change": "BatteryLevelPlugin.swift gains an isCharging case that reads UIDevice.current.batteryState and answers true for charging or full."
  },
  {
   "id": "M3",
   "change": "BatteryPlugin.kt now implements FlutterPlugin with a public no-argument constructor, sets up the channel in one private setup method shared by registerWith and onAttachedToEngine, and calls setMethodCallHandler(null) in onDetachedFromEngine."
  },
  {
   "id": "M4",
   "change": "BatteryPlugin.kt opens the channel as samples.flutter.dev/battery, the name Dart uses."
  },
  {
   "id": "X1",
   "change": "BatteryLevelPlugin.swift answers FlutterError with code UNAVAILABLE when batteryLevel is below 0 instead of returning a negative level."
  },
  {
   "id": "X2",
   "change": "BatteryPlugin.kt documents registerWith as the legacy v1 entry point."
  }
 ],
 "kept": [],
 "caveats": [
  "registerWith still uses the v1 Registrar; drop it if the plugin targets a Flutter release that no longer ships the v1 embedding.",
  "The iOS simulator reports batteryLevel as -1, so getBatteryLevel answers UNAVAILABLE there."
 ]
}

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

What step 5 prints for it: M1: pubspec.yaml now names BatteryPlugin as the Android pluginClass, keeping the Kotlin class name as declared. | M2: BatteryLevelPlugin.swift gains an isCharging case that reads UIDevice.current.batteryState and answers true for charging or full. | M3: BatteryPlugin.kt now implements FlutterPlugin with a public no-argument constructor, sets up the channel in one private setup method shared by registerWith and onAttachedToEngine, and calls setMethodCallHandler(null) in onDetachedFromEngine. | M4: BatteryPlugin.kt opens the channel as samples.flutter.dev/battery, the name Dart uses. | X1: BatteryLevelPlugin.swift answers FlutterError with code UNAVAILABLE when batteryLevel is below 0 instead of returning a negative level. | X2: BatteryPlugin.kt documents registerWith as the legacy v1 entry point.

The page's check: kept - nothing to flag

review - A share plugin that leaks and hangs on Android

The page read it as: Fix before release - 0 blocker, 3 major, 2 minor; findings M1 invoke-not-awaited, M2 activity-not-aware, M3 duplicated-init, M4 no-not-implemented, M5 no-detach-cleanup.

Input (code is 2211 characters; the first 1356 are shown; facts is 2027 characters; the first 1398 are shown):

{
 "task": "review",
 "unit": "Plugin: quick_share\nStage: an existing plugin\nFiles: lib/quick_share.dart (Dart), android/src/main/java/com/example/quick_share/QuickSharePlugin.java (Kotlin/Java)",
 "code": "// file: lib/quick_share.dart\nimport 'package:flutter/services.dart';\n\nclass QuickShare {\n  static const MethodChannel _channel = MethodChannel('quick_share');\n\n  static Future<void> shareText(String text) async {\n    _channel.invokeMethod('shareText', {'text': text});\n  }\n\n  static Future<bool> canShareFiles() async {\n    final ok = await _channel.invokeMethod<bool>('canShareFiles');\n    return ok ?? false;\n  }\n}\n\n// file: android/src/main/java/com/example/quick_share/QuickSharePlugin.java\npackage com.example.quick_share;\n\nimport android.app.Activity;\nimport android.content.Intent;\nimport androidx.annotation.NonNull;\nimport io.flutter.embedding.engine.plugins.FlutterPlugin;\nimport io.flutter.plugin.common.MethodCall;\nimport io.flutter.plugin.common.MethodChannel;\nimport io.flutter.plugin.common.MethodChannel.MethodCallHandler;\nimport io.flutter.plugin.common.MethodChannel.Result;\nimport io.flutter.plugin.common.PluginRegistry.Registrar;\n\npublic class QuickSharePlugin implements FlutterPlugin, MethodCallHandler {\n  private MethodChannel channel;\n  private Activity activity;\n\n  public QuickSharePlugin() {}\n\n  public static void registerWith(Registrar registrar) {\n    QuickSharePlugin plugin = new QuickSharePlugin();\n    plugin.activity = registrar.activity();\n    plugin.channel = new MethodChannel(registrar.messenger(), \"quick_share\");\n\n[... 855 more characters cut from this display ...]",
 "carry": "",
 "facts": "The page's scan of 67 lines in 2 files (lib/quick_share.dart = Dart, lines 2-16; android/src/main/java/com/example/quick_share/QuickSharePlugin.java = Kotlin/Java, lines 18-67); verdict: Fix before release - 0 blocker, 3 major, 2 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- M1 | dart | minor | line 8 | quick_share.dart | invokeMethod('shareText') is neither awaited nor returned, so a PlatformException from the native side goes unhandled. | source: _channel.invokeMethod('shareText', {'text': text});\n- M2 | lifecycle | major | line 30 | QuickSharePlugin | QuickSharePlugin holds an Activity but does not implement ActivityAware, so under the v2 embedding nothing ever hands it the Activity (it stays null). | source: public class QuickSharePlugin implements FlutterPlugin, MethodCallHandler {\n- M3 | embedding | minor | line 36 | QuickSharePlugin | registerWith and onAttachedToEngine each build the channel themselves - the skill extracts that into one private method both call, so the two entry points cannot drift. | source: public static void registerWith(Registrar registrar) {\n- M4 | methods | major | line 57 | QuickSharePlugin.java | The Android handler has no result.notImplemented() fallback, so any other method name leaves Dart's Future waiting forever. | source: } else if (call.method.equals(\"canShareFiles\")) {\n- M5 | lifecycle | major | line 63 |\n\n[... 629 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "Today shareText crashes on a null Activity under the v2 embedding and its errors go unhandled in Dart; first implement ActivityAware so the plugin receives the Activity.",
 "findings": [
  {
   "id": "M1",
   "widget": "quick_share.dart - shareText",
   "issue": "The Future from invokeMethod is dropped, so a PlatformException from Android is never seen by the caller and surfaces as an unhandled error.",
   "fix": "Await the invokeMethod call inside shareText.",
   "code": "static Future<void> shareText(String text) async {\n  await _channel.invokeMethod('shareText', {'text': text});\n}"
  },
  {
   "id": "M2",
   "widget": "QuickSharePlugin.java - QuickSharePlugin",
   "issue": "Under the v2 embedding nothing sets the activity field, so shareText calls startActivity on null and the app crashes with a NullPointerException.",
   "fix": "Implement ActivityAware and set the Activity in onAttachedToActivity and onReattachedToActivityForConfigChanges, clearing it in onDetachedFromActivityForConfigChanges and onDetachedFromActivity.",
   "code": "@Override\npublic void onAttachedToActivity(@NonNull ActivityPluginBinding binding) {\n  activity = binding.getActivity();\n}\n@Override\npublic void onDetachedFromActivity() { activity = null; }"
  },
  {
   "id": "M3",
   "widget": "QuickSharePlugin.java - registerWith",
   "issue": "registerWith and onAttachedToEngine each build the channel, so the two entry points can drift apart.",
   "fix": "Move the channel setup into one private method that registerWith and onAttachedToEngine both call.",
   "code": "private void setup(BinaryMessenger messenger) {\n  channel = new MethodChannel(messenger, \"quick_share\");\n  channel.setMethodCallHandler(this);\n}"
  },
  {
   "id": "M4",
   "widget": "QuickSharePlugin.java - onMethodCall",
   "issue": "Any method name other than shareText or canShareFiles never gets an answer, so the Dart Future waits forever.",
   "fix": "End the if chain with an else branch that calls result.notImplemented().",
   "code": "} else {\n  result.notImplemented();\n}"
  },
  {
   "id": "M5",
   "widget": "QuickSharePlugin.java - onDetachedFromEngine",
   "issue": "The handler is never cleared, so it and the Activity it holds outlive the engine.",
   "fix": "Call setMethodCallHandler(null) on the channel before setting the field to null.",
   "code": "channel.setMethodCallHandler(null);\nchannel = null;"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "lifecycle",
   "line": 55,
   "widget": "QuickSharePlugin.java - onMethodCall",
   "issue": "If shareText arrives while no Activity is attached, startActivity throws instead of reaching Dart as a PlatformException.",
   "fix": "Check the activity field first and answer result.error(\"NO_ACTIVITY\", \"No foreground Activity\", null) when it is null."
  },
  {
   "id": "X2",
   "area": "android",
   "line": 36,
   "widget": "QuickSharePlugin.java - registerWith",
   "issue": "The public registerWith method and the public constructor on line 34 are not overrides and carry no documentation.",
   "fix": "Add Javadoc comments to the public constructor and registerWith."
  }
 ],
 "plan": [
  "Implement ActivityAware with all four callbacks so the Activity is set and cleared (M2)",
  "Share one private setup method between registerWith and onAttachedToEngine (M3)",
  "Add the result.notImplemented() fallback to onMethodCall (M4)",
  "Answer result.error when shareText runs with no Activity (X1)",
  "Clear the handler with setMethodCallHandler(null) in onDetachedFromEngine (M5)",
  "Await invokeMethod in shareText (M1)",
  "Document the public constructor and registerWith (X2)"
 ],
 "caveats": [
  "No iOS or pubspec.yaml file was pasted, so the iOS side and pluginClass are unchecked."
 ]
}

What step 5 prints for it: M1: Await the invokeMethod call inside shareText. | M2: Implement ActivityAware and set the Activity in onAttachedToActivity and onReattachedToActivityForConfigChanges, clearing it in onDetachedFromActivityForConfigChanges and onDetachedFromActivity. | M3: Move the channel setup into one private method that registerWith and onAttachedToEngine both call. | M4: End the if chain with an else branch that calls result.notImplemented(). | M5: Call setMethodCallHandler(null) on the channel before setting the field to null.

The page's check: kept - nothing to flag

Build the input yourself

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

const K = ChannelKit, R = ChannelReply;
const f = K.form({ name: "tick", targets: "existing", carry: "",
  code: "// file: lib/t.dart\nconst _c = MethodChannel('tick');\nFuture<int?> now() => _c.invokeMethod<int>('now');\n// file: ios/T.swift\nlet c = FlutterMethodChannel(name: \"ticks\", binaryMessenger: m)" });
// f.findings, f.scan.methods (every method on each side), f.scan.channels, f.checklist, f.steps (the plugin commands)
// K.findingsCsv(f), K.methodsCsv(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://channel-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":"channel-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", "unit": "Plugin: battery_level
Stage: an existing plugin
Files: pubspec.yaml (pubspec.yaml), lib/battery_level.dart (Dart), ...", "code": "# file: pubspec.yaml
name: battery_level
...", "carry": "", "facts": "The page's scan of 80 lines in 4 files ...
Findings (id | area | severity | line | where | evidence | source):
- M1 | pubspec | 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 channel-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="channel-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: channel-lens:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json