← Widget Bridge
API
Tokens

Run Widget Bridge 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 App Group, the widget names updateWidget asks for, every key written and read with its type, the view ids and the manifest receiver, 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 (bridgereply.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":"widget-bridge"}), 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 widget's name, its platforms and the files pasted.
codeyesthe files, one after another, each starting with a line "// file: path" (XML files: "<!-- 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 key map, one key per line as key | Dart writes | iOS reads | Android reads | status; and the names (App Group, iOS widget kind, Android provider, layout) 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-adding-home-screen-widgets - 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 the widget shows today (often nothing, or stale or default values), 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, e.g. "NewsWidget.kt - NewsWidget.onUpdate"), issue (one sentence: what the user sees on the home screen), 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 (names, keys, ios, android or dart), line (a number), widget, issue, fix. Examples: a key built at run time that may not match, a timeline policy that never refreshes, a placeholder that crashes or shows raw keys, an image path the native side never loads, a widget the Flutter side updates before the app group is set, a one-side-only key the other platform should also show. [] when there are none.
  5. plan - 2 to 10 short strings, the fixes in the order to make them (what keeps the widget blank or the build failing first, then names and the App Group, then keys and types, then the rest), each naming the ids it covers (e.g. "Use one App Group ID in Dart and Swift (B2)"). 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": "B1", "widget": "widget_sync.dart - pushHeadline", "issue": "one sentence", "fix": "one sentence", "code": "await HomeWidget.updateWidget(iOSName: 'NewsWidgets', androidName: 'NewsWidget');" } ],
  "cleared": [ { "id": "B4", "why": "one sentence" } ],
  "extra": [ { "id": "X1", "area": "ios", "line": 40, "widget": "NewsWidgets.swift - Provider.getTimeline", "issue": "one sentence", "fix": "one sentence" } ],
  "plan": [ "Use one App Group ID in Dart and Swift (B2)" ],
  "caveats": [ ]
}

The page holds it to its own facts (check() in bridgereply.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:" (or "<!-- file: -->") line. Keep every import, class, struct, function and layout view, with every 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. App Group missing or different - one ID, set with await HomeWidget.setAppGroupId before any save, the same string in UserDefaults(suiteName:). UserDefaults.standard - UserDefaults(suiteName: the App Group). updateWidget missing a name or naming the wrong one - iOSName is the Widget struct's kind string, androidName the provider's class name. A key read but never written, or spelt two ways - use the key the Flutter side writes. A type mismatch - read with the getter that matches the saved type (integer(forKey:), getInt; on Android a double saved from Dart is read with getLong and java.lang.Double.longBitsToDouble), or change the save to the type the widget shows. A force-unwrapped read - ?? with a placeholder. A provider that never calls updateAppWidget - call appWidgetManager.updateAppWidget(appWidgetId, views) inside the loop; its own SharedPreferences - HomeWidgetPlugin.getData(context). A view id the layout lacks - the id the layout defines (or add the id to the layout when the layout is pasted). A save not awaited - await it before updateWidget. A provider missing from a pasted manifest - a receiver with the APPWIDGET_UPDATE intent filter and the android.appwidget.provider meta-data.
  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": "B1", "change": "one sentence" } ],
  "kept": [ { "id": "B4", "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 news widget whose Flutter and native sides never meet, the rewrite of the same files carrying that review, and the review of a step counter that writes one type and reads another. 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 news widget whose two sides never meet

The page read it as: Widget stays blank - 3 blocker, 2 major, 0 minor; findings B1 ios-name-mismatch, B2 group-mismatch, B3 no-update-call, B4 key-unwritten, B5 missing-view-id.

Input (code is 3193 characters; the first 1380 are shown; facts is 2493 characters; the first 1399 are shown):

{
 "task": "review",
 "unit": "Widget: News headline widget\nPlatforms: iOS and Android\nFiles: lib/news/widget_sync.dart (Dart), ios/NewsWidgets/NewsWidgets.swift (Swift), android/app/src/main/kotlin/com/example/news/NewsWidget.kt (Kotlin/Java), android/app/src/main/res/layout/news_widget.xml (Layout XML)",
 "code": "// file: lib/news/widget_sync.dart\nimport 'package:home_widget/home_widget.dart';\n\nconst String appGroupId = 'group.com.example.news';\n\nFuture<void> pushHeadline(String title, String description) async {\n  await HomeWidget.setAppGroupId(appGroupId);\n  await HomeWidget.saveWidgetData<String>('headline_title', title);\n  await HomeWidget.saveWidgetData<String>('headline_description', description);\n  await HomeWidget.updateWidget(iOSName: 'NewsWidget', androidName: 'NewsWidget');\n}\n\n// file: ios/NewsWidgets/NewsWidgets.swift\nimport WidgetKit\nimport SwiftUI\n\nstruct NewsEntry: TimelineEntry {\n    let date: Date\n    let title: String\n    let description: String\n}\n\nstruct Provider: TimelineProvider {\n    func placeholder(in context: Context) -> NewsEntry {\n        NewsEntry(date: Date(), title: \"Loading...\", description: \"\")\n    }\n\n    func getSnapshot(in context: Context, completion: @escaping (NewsEntry) -> ()) {\n        let defaults = UserDefaults(suiteName: \"group.com.example.app\")\n        let title = defaults?.string(forKey: \"headline_title\") ?? \"No title\"\n        let description = defaults?.string(forKey: \"headline_description\") ?? \"\"\n        completion(NewsEntry(date: Date(), title: title, description: description))\n    }\n\n    func getTimeline(in context: Context, completion: @escaping (Timeline<NewsEntry>) -> ()) {\n        getSnapshot(in: context) { entry in\n\n[... 1813 more characters cut from this display ...]",
 "carry": "",
 "facts": "The page's scan of 79 lines in 4 files (lib/news/widget_sync.dart = Dart, lines 2-12; ios/NewsWidgets/NewsWidgets.swift = Swift, lines 14-51; android/app/src/main/kotlin/com/example/news/NewsWidget.kt = Kotlin/Java, lines 53-72; android/app/src/main/res/layout/news_widget.xml = Layout XML, lines 74-79); verdict: Widget stays blank - 3 blocker, 2 major, 0 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- B1 | names | major | line 10 | updateWidget | updateWidget asks iOS to reload 'NewsWidget', but the widget's kind is 'NewsWidgets' - WidgetCenter reloads by kind, so nothing refreshes. | source: await HomeWidget.updateWidget(iOSName: 'NewsWidget', androidName: 'NewsWidget');\n- B2 | names | blocker | line 29 | App Group | The App Group differs: Dart sets 'group.com.example.news' (line 7) but the widget reads UserDefaults(suiteName: \"group.com.example.app\") - the two sides never see the same storage. | source: let defaults = UserDefaults(suiteName: \"group.com.example.app\")\n- B3 | android | blocker | line 61 | NewsWidget | NewsWidget never calls appWidgetManager.updateAppWidget, so the RemoteViews it builds are never shown. | source: class NewsWidget : AppWidgetProvider() {\n- B4 | keys | major | line 67 | NewsWidget.kt | The Android widget reads 'headline_desc', which the Flutter side never saves (it writes 'headline_title', 'headline_description') - the\n\n[... 1094 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "Today the iOS widget shows No title because it reads a different App Group, and the Android widget never appears; first use group.com.example.news in Swift and call appWidgetManager.updateAppWidget.",
 "findings": [
  {
   "id": "B1",
   "widget": "widget_sync.dart - pushHeadline",
   "issue": "On iOS the home screen widget is never asked to reload, so a new headline stays hidden until the system refreshes on its own.",
   "fix": "Pass the widget's kind string NewsWidgets as iOSName and keep NewsWidget as androidName.",
   "code": "await HomeWidget.updateWidget(iOSName: 'NewsWidgets', androidName: 'NewsWidget');"
  },
  {
   "id": "B2",
   "widget": "NewsWidgets.swift - Provider.getSnapshot",
   "issue": "The iOS widget always shows No title and an empty description because it opens a storage suite the app never writes.",
   "fix": "Use the App Group the Flutter side sets, group.com.example.news, in UserDefaults(suiteName:).",
   "code": "let defaults = UserDefaults(suiteName: \"group.com.example.news\")"
  },
  {
   "id": "B3",
   "widget": "NewsWidget.kt - NewsWidget.onUpdate",
   "issue": "The Android widget stays blank because the RemoteViews built in the loop are never handed to the widget manager.",
   "fix": "Call appWidgetManager.updateAppWidget(appWidgetId, views) at the end of each loop pass.",
   "code": "appWidgetManager.updateAppWidget(appWidgetId, views)"
  },
  {
   "id": "B4",
   "widget": "NewsWidget.kt - NewsWidget.onUpdate",
   "issue": "The Android widget never shows the headline description because it reads headline_desc, a key the app never saves.",
   "fix": "Read headline_description, the key the Flutter side writes.",
   "code": "setTextViewText(R.id.headline_description, widgetData.getString(\"headline_description\", \"\"))"
  },
  {
   "id": "B5",
   "widget": "NewsWidget.kt - NewsWidget.onUpdate",
   "issue": "The Android build fails because R.id.headline_body is not defined in news_widget.xml, so the widget never reaches the home screen.",
   "fix": "Set the text on R.id.headline_description, the id the pasted layout defines.",
   "code": "setTextViewText(R.id.headline_description, widgetData.getString(\"headline_description\", \"\"))"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "ios",
   "line": 36,
   "widget": "NewsWidgets.swift - Provider.getTimeline",
   "issue": "The single-entry timeline with the atEnd policy asks WidgetKit for a new timeline right away, spending the refresh budget on reads that return the same headline.",
   "fix": "Use the never policy, since the app already asks for a reload through HomeWidget.updateWidget after each save."
  }
 ],
 "plan": [
  "Fix the Android layout id so the build passes and call appWidgetManager.updateAppWidget in the loop (B5, B3)",
  "Use the App Group group.com.example.news in Swift as well as Dart (B2)",
  "Pass the iOS kind NewsWidgets as iOSName in HomeWidget.updateWidget (B1)",
  "Read headline_description on Android, the key Dart writes (B4)",
  "Switch the iOS timeline policy to never (X1)"
 ],
 "caveats": [
  "The App Groups capability with group.com.example.news must be on both the Runner target and the widget extension; the project settings were not pasted.",
  "The AndroidManifest.xml was not pasted, so the NewsWidget receiver registration could not be checked."
 ]
}

What step 5 prints for it: B1: Pass the widget's kind string NewsWidgets as iOSName and keep NewsWidget as androidName. | B2: Use the App Group the Flutter side sets, group.com.example.news, in UserDefaults(suiteName:). | B3: Call appWidgetManager.updateAppWidget(appWidgetId, views) at the end of each loop pass. | B4: Read headline_description, the key the Flutter side writes. | B5: Set the text on R.id.headline_description, the id the pasted layout defines.

The page's check: kept - nothing to flag

rewrite - A news widget whose two sides never meet

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

Input (code is 3193 characters; the first 1380 are shown; facts is 2485 characters; the first 1399 are shown):

{
 "task": "rewrite",
 "unit": "Widget: News headline widget\nPlatforms: iOS and Android\nFiles: lib/news/widget_sync.dart (Dart), ios/NewsWidgets/NewsWidgets.swift (Swift), android/app/src/main/kotlin/com/example/news/NewsWidget.kt (Kotlin/Java), android/app/src/main/res/layout/news_widget.xml (Layout XML)",
 "code": "// file: lib/news/widget_sync.dart\nimport 'package:home_widget/home_widget.dart';\n\nconst String appGroupId = 'group.com.example.news';\n\nFuture<void> pushHeadline(String title, String description) async {\n  await HomeWidget.setAppGroupId(appGroupId);\n  await HomeWidget.saveWidgetData<String>('headline_title', title);\n  await HomeWidget.saveWidgetData<String>('headline_description', description);\n  await HomeWidget.updateWidget(iOSName: 'NewsWidget', androidName: 'NewsWidget');\n}\n\n// file: ios/NewsWidgets/NewsWidgets.swift\nimport WidgetKit\nimport SwiftUI\n\nstruct NewsEntry: TimelineEntry {\n    let date: Date\n    let title: String\n    let description: String\n}\n\nstruct Provider: TimelineProvider {\n    func placeholder(in context: Context) -> NewsEntry {\n        NewsEntry(date: Date(), title: \"Loading...\", description: \"\")\n    }\n\n    func getSnapshot(in context: Context, completion: @escaping (NewsEntry) -> ()) {\n        let defaults = UserDefaults(suiteName: \"group.com.example.app\")\n        let title = defaults?.string(forKey: \"headline_title\") ?? \"No title\"\n        let description = defaults?.string(forKey: \"headline_description\") ?? \"\"\n        completion(NewsEntry(date: Date(), title: title, description: description))\n    }\n\n    func getTimeline(in context: Context, completion: @escaping (Timeline<NewsEntry>) -> ()) {\n        getSnapshot(in: context) { entry in\n\n[... 1813 more characters cut from this display ...]",
 "carry": "B1 | confirmed | widget_sync.dart - pushHeadline | Pass the widget's kind string NewsWidgets as iOSName and keep NewsWidget as androidName.\nB2 | confirmed | NewsWidgets.swift - Provider.getSnapshot | Use the App Group the Flutter side sets, group.com.example.news, in UserDefaults(suiteName:).\nB3 | confirmed | NewsWidget.kt - NewsWidget.onUpdate | Call appWidgetManager.updateAppWidget(appWidgetId, views) at the end of each loop pass.\nB4 | confirmed | NewsWidget.kt - NewsWidget.onUpdate | Read headline_description, the key the Flutter side writes.\nB5 | confirmed | NewsWidget.kt - NewsWidget.onUpdate | Set the text on R.id.headline_description, the id the pasted layout defines.\nX1 | extra | NewsWidgets.swift - Provider.getTimeline (line 36) | Use the never policy, since the app already asks for a reload through HomeWidget.updateWidget after each save.",
 "facts": "The page's scan of 79 lines in 4 files (lib/news/widget_sync.dart = Dart, lines 2-12; ios/NewsWidgets/NewsWidgets.swift = Swift, lines 14-51; android/app/src/main/kotlin/com/example/news/NewsWidget.kt = Kotlin/Java, lines 53-72; android/app/src/main/res/layout/news_widget.xml = Layout XML, lines 74-79); verdict: Widget stays blank - 3 blocker, 2 major, 0 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- B1 | names | major | line 10 | updateWidget | updateWidget asks iOS to reload 'NewsWidget', but the widget's kind is 'NewsWidgets' - WidgetCenter reloads by kind, so nothing refreshes. | source: await HomeWidget.updateWidget(iOSName: 'NewsWidget', androidName: 'NewsWidget');\n- B2 | names | blocker | line 29 | App Group | The App Group differs: Dart sets 'group.com.example.news' (line 7) but the widget reads UserDefaults(suiteName: \"group.com.example.app\") - the two sides never see the same storage. | source: let defaults = UserDefaults(suiteName: \"group.com.example.app\")\n- B3 | android | blocker | line 61 | NewsWidget | NewsWidget never calls appWidgetManager.updateAppWidget, so the RemoteViews it builds are never shown. | source: class NewsWidget : AppWidgetProvider() {\n- B4 | keys | major | line 67 | NewsWidget.kt | The Android widget reads 'headline_desc', which the Flutter side never saves (it writes 'headline_title', 'headline_description') - the\n\n[... 1086 more characters cut from this display ...]"
}

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

{
 "task": "rewrite",
 "summary": "The rewrite points iOSName at the NewsWidgets kind, aligns the App Group, fixes the Android key and view id, adds updateAppWidget and the never policy; the widget info XML and manifest stay for the team.",
 "code": "// file: lib/news/widget_sync.dart\nimport 'package:home_widget/home_widget.dart';\n\nconst String appGroupId = 'group.com.example.news';\n\nFuture<void> pushHeadline(String title, String description) async {\n  await HomeWidget.setAppGroupId(appGroupId);\n  await HomeWidget.saveWidgetData<String>('headline_title', title); [... 2956 more characters cut from this display]",
 "changes": [
  {
   "id": "B1",
   "change": "widget_sync.dart now passes NewsWidgets, the Widget struct's kind string, as iOSName and keeps NewsWidget as androidName."
  },
  {
   "id": "B2",
   "change": "NewsWidgets.swift now reads UserDefaults(suiteName: \"group.com.example.news\"), the App Group the Flutter side sets."
  },
  {
   "id": "B3",
   "change": "NewsWidget.kt now calls appWidgetManager.updateAppWidget(appWidgetId, views) at the end of each loop pass."
  },
  {
   "id": "B4",
   "change": "NewsWidget.kt now reads headline_description, the key the Flutter side writes, instead of headline_desc."
  },
  {
   "id": "B5",
   "change": "NewsWidget.kt now sets the text on R.id.headline_description, the id news_widget.xml defines."
  },
  {
   "id": "X1",
   "change": "NewsWidgets.swift getTimeline now uses the never policy, since HomeWidget.updateWidget asks for a reload after each save."
  }
 ],
 "kept": [],
 "caveats": [
  "The App Groups capability with group.com.example.news must be on both the Runner target and the NewsWidgets extension in Xcode.",
  "The manifest receiver for NewsWidget was not pasted, so its registration is not checked here."
 ]
}

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

What step 5 prints for it: B1: widget_sync.dart now passes NewsWidgets, the Widget struct's kind string, as iOSName and keeps NewsWidget as androidName. | B2: NewsWidgets.swift now reads UserDefaults(suiteName: "group.com.example.news"), the App Group the Flutter side sets. | B3: NewsWidget.kt now calls appWidgetManager.updateAppWidget(appWidgetId, views) at the end of each loop pass. | B4: NewsWidget.kt now reads headline_description, the key the Flutter side writes, instead of headline_desc. | B5: NewsWidget.kt now sets the text on R.id.headline_description, the id news_widget.xml defines. | X1: NewsWidgets.swift getTimeline now uses the never policy, since HomeWidget.updateWidget asks for a reload after each save.

The page's check: kept - nothing to flag

review - A step counter that writes one type and reads another

The page read it as: Fix before release - 0 blocker, 5 major, 0 minor; findings B1 not-awaited, B2 not-awaited, B3 forced-read, B4 type-mismatch, B5 android-own-prefs.

Input (code is 2332 characters; the first 1367 are shown; facts is 2304 characters; the first 1394 are shown):

{
 "task": "review",
 "unit": "Widget: Step counter widget\nPlatforms: iOS and Android\nFiles: lib/steps/step_widget.dart (Dart), ios/StepsWidget/StepsWidget.swift (Swift), android/app/src/main/kotlin/com/example/steps/StepsWidgetProvider.kt (Kotlin/Java)",
 "code": "// file: lib/steps/step_widget.dart\nimport 'package:home_widget/home_widget.dart';\n\nclass StepWidgetSync {\n  static const group = 'group.com.example.steps';\n\n  Future<void> publish(int steps, int goal) async {\n    await HomeWidget.setAppGroupId(group);\n    HomeWidget.saveWidgetData<int>('steps_today', steps);\n    HomeWidget.saveWidgetData<int>('steps_goal', goal);\n    await HomeWidget.updateWidget(iOSName: 'StepsWidget', androidName: 'StepsWidgetProvider');\n  }\n}\n\n// file: ios/StepsWidget/StepsWidget.swift\nimport WidgetKit\nimport SwiftUI\n\nstruct StepsEntry: TimelineEntry {\n    let date: Date\n    let steps: String\n}\n\nstruct StepsProvider: TimelineProvider {\n    func placeholder(in context: Context) -> StepsEntry { StepsEntry(date: Date(), steps: \"-\") }\n    func getSnapshot(in context: Context, completion: @escaping (StepsEntry) -> ()) {\n        let defaults = UserDefaults(suiteName: \"group.com.example.steps\")\n        let steps = defaults?.string(forKey: \"steps_today\")!\n        completion(StepsEntry(date: Date(), steps: steps ?? \"-\"))\n    }\n    func getTimeline(in context: Context, completion: @escaping (Timeline<StepsEntry>) -> ()) {\n        getSnapshot(in: context) { entry in completion(Timeline(entries: [entry], policy: .atEnd)) }\n    }\n}\n\nstruct StepsWidget: Widget {\n    let kind: String = \"StepsWidget\"\n    var body: some WidgetConfiguration {\n\n[... 965 more characters cut from this display ...]",
 "carry": "",
 "facts": "The page's scan of 62 lines in 3 files (lib/steps/step_widget.dart = Dart, lines 2-14; ios/StepsWidget/StepsWidget.swift = Swift, lines 16-42; android/app/src/main/kotlin/com/example/steps/StepsWidgetProvider.kt = Kotlin/Java, lines 44-62); verdict: Fix before release - 0 blocker, 5 major, 0 minor.\nFindings (id | area | severity | line | where | evidence | source):\n- B1 | dart | major | line 9 | HomeWidget.saveWidgetData | HomeWidget.saveWidgetData is not awaited, so updateWidget can fire before the value is stored and the widget redraws with the old one. | source: HomeWidget.saveWidgetData<int>('steps_today', steps);\n- B2 | dart | major | line 10 | HomeWidget.saveWidgetData | HomeWidget.saveWidgetData is not awaited, so updateWidget can fire before the value is stored and the widget redraws with the old one. | source: HomeWidget.saveWidgetData<int>('steps_goal', goal);\n- B3 | ios | major | line 28 | StepsWidget.swift | The read of 'steps_today' is force-unwrapped - before the app first saves it, the widget extension crashes and shows nothing. | source: let steps = defaults?.string(forKey: \"steps_today\")!\n- B4 | keys | major | line 28 | StepsWidget.swift | 'steps_today' is saved as int (line 9) but read with string - the read returns nil/the default (or throws a ClassCastException on Android). | source: let steps = defaults?.string(forKey: \"steps_today\")!\n- B5 | android |\n\n[... 910 more characters cut from this display ...]"
}

Reply:

{
 "task": "review",
 "summary": "Today the iOS widget shows a dash or crashes and the Android widget shows 0 steps, since neither reads what the app saves; first read through HomeWidgetPlugin.getData(context) and fix the iOS read.",
 "findings": [
  {
   "id": "B1",
   "widget": "step_widget.dart - StepWidgetSync.publish",
   "issue": "The widget can redraw with the previous step count because the update fires before the save finishes.",
   "fix": "Await the steps_today save before HomeWidget.updateWidget.",
   "code": "await HomeWidget.saveWidgetData<int>('steps_today', steps);"
  },
  {
   "id": "B2",
   "widget": "step_widget.dart - StepWidgetSync.publish",
   "issue": "The goal shown on Android can lag one update behind because its save is not awaited.",
   "fix": "Await the steps_goal save before HomeWidget.updateWidget.",
   "code": "await HomeWidget.saveWidgetData<int>('steps_goal', goal);"
  },
  {
   "id": "B3",
   "widget": "StepsWidget.swift - StepsProvider.getSnapshot",
   "issue": "Before the app first saves a value the widget extension crashes on the force-unwrapped read, so the home screen shows nothing.",
   "fix": "Drop the force unwrap and fall back with ?? to a placeholder.",
   "code": "let steps = defaults?.integer(forKey: \"steps_today\") ?? 0\ncompletion(StepsEntry(date: Date(), steps: String(steps)))"
  },
  {
   "id": "B4",
   "widget": "StepsWidget.swift - StepsProvider.getSnapshot",
   "issue": "The iOS widget never shows the step count because an int saved by Dart is read with string(forKey:), which returns nil.",
   "fix": "Read steps_today with integer(forKey:) and turn it into text for the entry.",
   "code": "let steps = defaults?.integer(forKey: \"steps_today\") ?? 0\ncompletion(StepsEntry(date: Date(), steps: String(steps)))"
  },
  {
   "id": "B5",
   "widget": "StepsWidgetProvider.kt - StepsWidgetProvider.onUpdate",
   "issue": "The Android widget always shows 0 steps and a 0 goal because it reads its own preferences file, not the one home_widget writes.",
   "fix": "Get the data with HomeWidgetPlugin.getData(context) and add its import.",
   "code": "import es.antonborri.home_widget.HomeWidgetPlugin\n\nval prefs = HomeWidgetPlugin.getData(context)"
  }
 ],
 "cleared": [],
 "extra": [
  {
   "id": "X1",
   "area": "keys",
   "line": 21,
   "widget": "StepsWidget.swift - StepsEntry",
   "issue": "The app saves steps_goal and Android shows it, but the iOS entry has no goal field, so iOS users never see their goal.",
   "fix": "Add a goal field to StepsEntry, read steps_goal with integer(forKey:) and show it in the view."
  }
 ],
 "plan": [
  "Read the shared data with HomeWidgetPlugin.getData(context) in the Android provider (B5)",
  "Remove the force unwrap and read steps_today with integer(forKey:) on iOS (B3, B4)",
  "Await both saveWidgetData calls before HomeWidget.updateWidget (B1, B2)",
  "Show steps_goal on iOS too (X1)"
 ],
 "caveats": [
  "The steps_widget layout was not pasted, so the ids R.id.steps and R.id.goal could not be checked.",
  "The AndroidManifest.xml was not pasted, so the StepsWidgetProvider receiver registration could not be checked."
 ]
}

What step 5 prints for it: B1: Await the steps_today save before HomeWidget.updateWidget. | B2: Await the steps_goal save before HomeWidget.updateWidget. | B3: Drop the force unwrap and fall back with ?? to a placeholder. | B4: Read steps_today with integer(forKey:) and turn it into text for the entry. | B5: Get the data with HomeWidgetPlugin.getData(context) and add its import.

The page's check: kept - nothing to flag

Build the input yourself

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

const K = BridgeKit, R = BridgeReply;
const f = K.form({ name: "Headline widget", targets: "both", carry: "",
  code: "// file: lib/w.dart\nFuture<void> f() async {\n  await HomeWidget.saveWidgetData<String>('title', 'x');\n}\n// file: ios/W.swift\nlet t = UserDefaults.standard.string(forKey: \"title\")" });
// f.findings, f.scan.keymap (every key on each side), f.scan.names, f.checklist, f.steps (the native setup steps)
// K.findingsCsv(f), K.keysCsv(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://widget-bridge.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":"widget-bridge"}' | 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": "Widget: News headline widget
Platforms: iOS and Android
Files: lib/news/widget_sync.dart (Dart), ...", "code": "// file: lib/news/widget_sync.dart
import 'package:home_widget/home_widget.dart';
...", "carry": "", "facts": "The page's scan of 80 lines in 4 files ...
Findings (id | area | severity | line | where | evidence | source):
- B1 | names | major | line 10 | ..."}
# - 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 widget-bridge:<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="widget-bridge:$(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: widget-bridge:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json