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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The 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. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. 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:
review- each of the page's findings confirmed (with the issue, the fix and a short code fix) or cleared (with why), up to five issues a scan cannot see on real lines, and the fixes in the order to make them, each naming its ids.rewrite- every pasted file corrected under its own file line - one channel name, every invoked method handled, the not-implemented fallback, FlutterPlugin with cleanup on detach, ActivityAware when an Activity is held, pluginClass matching the code - with what changed and what was kept, every file and class name unchanged.
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
| Field | Required | What |
|---|---|---|
task | yes | review or rewrite. If it is missing or neither, do review and set "task": "review". |
unit | yes | the plugin's name, whether it is an existing plugin or a new one being set up, and the files pasted. |
code | yes | the 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. |
carry | send 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. |
facts | yes | the 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_note | no | optional; 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:
- Standard plugins (flutter create --template=plugin) use method channels for platform APIs; FFI plugins (--template=plugin_ffi) bundle native code and can register method channels but do not support method channels themselves - a plugin that needs both uses the standard template.
- Android, v2 embedding: the plugin class implements FlutterPlugin and has a public constructor. The setup shared by the legacy registerWith() and onAttachedToEngine() lives in one private method that both call, so the logic is not duplicated.
- A plugin that needs an Activity implements ActivityAware and handles onAttachedToActivity, onDetachedFromActivityForConfigChanges, onReattachedToActivityForConfigChanges and onDetachedFromActivity; one that runs in a background Service implements ServiceAware.
- The example app's MainActivity extends io.flutter.embedding.android.FlutterActivity. Document every public member of the Android implementation that is not an override.
- The skill's example handler matches each method name, answers with result.success, ends with result.notImplemented() for anything else, and clears the handler with setMethodCallHandler(null) in onDetachedFromEngine.
- The page also applies what the channel itself requires: the channel name is the same string on every side, every method Dart invokes is handled on every platform it supports, iOS and macOS register through register(with:) with addMethodCallDelegate, and pubspec's pluginClass (and Android package) names the real class.
House rules
- Use only the input. Never invent a method, a channel name, a class of the plugin's own, a package or a file the paste does not hold; Flutter embedding and platform SDK types a fix needs (ActivityPluginBinding, BinaryMessenger, FlutterError) are fine. When the sides disagree, Dart's channel name and method names win (the app-facing API is what callers use), while class names win over pubspec's pluginClass (renaming a class would break the rest); say which you picked. When a fix needs a file that was not pasted (another platform, the example app, a federated package), say so in a caveat instead of guessing its contents.
- The page's ids and lines are measured facts: never renumber an id.
- No web links (http or https) in prose.
- Write in English. Keep the JSON keys and the ids exactly as given.
- Never use backticks. Name calls plainly (result.notImplemented(), setMethodCallHandler(null)). Words from the rules above are free to use; what you never do is paste a whole sentence from this prompt.
- Never write a gendered pronoun for a user; write they, the user or the person.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
review
Confirm or clear each of the page's findings, add what a scan cannot see, and put the fixes in order.
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.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).cleared- one object per page finding that is not a problem in this code, withidandwhy(one sentence tied to the code). Every page id appears exactly once, in findings or in cleared.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.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.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.
summary- one sentence, at most 40 words: what the rewrite changes and what it leaves for the team.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).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.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.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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://channel-lens.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// channel-lens.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://channel-lens.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://channel-lens.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// ChannelLens.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class ChannelLens {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://channel-lens.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://channel-lens.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://channel-lens.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://channel-lens.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
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')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "channel-lens"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "channel-lens" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "channel-lens"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "channel-lens"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "channel-lens" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "channel-lens"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "channel-lens" }))!["token"]!.GetValue<string>();
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}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["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}'
inp = json.load(open("input.json")) # {"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
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "review" or "rewrite", unit, code, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
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
import hashlib, time
key = "channel-lens:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "rewrite":
for x in reply["changes"]:
print(x["id"], x["change"])
else:
for x in reply["findings"]:
print(x["id"], x["fix"])
import { createHash } from "node:crypto";
const key = `channel-lens:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "rewrite") { for (const x of reply.changes) console.log(x.id, x.change); }
else { for (const x of reply.findings) console.log(x.id, x.fix); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "channel-lens:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
key, b := "findings", "fix"
if reply["task"] == "rewrite" {
key, b = "changes", "change"
}
xs, _ := reply[key].([]any)
for _, x := range xs {
xx := x.(map[string]any)
fmt.Println(xx["id"], xx[b])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "channel-lens:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("rewrite")) {
for (JsonNode x : reply.path("changes")) System.out.println(x.path("id").asText() + " " + x.path("change").asText());
} else {
for (JsonNode x : reply.path("findings")) System.out.println(x.path("id").asText() + " " + x.path("fix").asText());
}
require "digest"
key = "channel-lens:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "rewrite"
reply["changes"].each { |x| puts "#{x["id"]} #{x["change"]}" }
else
reply["findings"].each { |x| puts "#{x["id"]} #{x["fix"]}" }
end
$key = "channel-lens:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "rewrite") { foreach ($reply["changes"] as $x) echo $x["id"], " ", $x["change"], "\n"; }
else { foreach ($reply["findings"] as $x) echo $x["id"], " ", $x["fix"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"channel-lens:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "rewrite") { foreach (var x in reply["changes"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["change"]}"); }
else { foreach (var x in reply["findings"]!.AsArray()) Console.WriteLine($"{x!["id"]} {x["fix"]}"); }
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "channel-lens:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `channel-lens:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "channel-lens:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "channel-lens:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "channel-lens:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: channel-lens:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"channel-lens:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);