← Ext Desk / API
Tokens

Drive Ext Desk from your own code

Base URL https://api.skillsafe.ai/v1/app-api. One extension bundle goes in — at minimum manifest.json, usually the service worker and the content scripts beside it — and one structured result comes back. Everything on this page uses the same token the web app uses, which you can read off the tokens page.

The task field comes first

This app has four lanes over one bundle, and task selects which one you get. It is the field to decide before any other, because it changes the reply’s shape: the body key you get back, the posture vocabulary, the area vocabulary, the finding id prefix, the artifacts and the price all follow from it. Omit it and the model picks the lane your input best fits and names its choice in verdict — convenient interactively, not something to rely on in a script.

taskLaneWhat it readsBody keypostureArtifacts
manifestManifest and permissionsEvery key in manifest.json, in the order that actually bites: the version string, the MV2 hangovers (browser_action, background.scripts, a string CSP, a flat web_accessible_resources), a host pattern sitting in permissions where MV3 ignores it, breadth, unused and missing permissions, the CSP, what web_accessible_resources exposes, the Chrome floor and the __MSG_ localisation trap. The code files are evidence about whether the manifest matches what the extension does.keys[]mv3-clean / mv3-fixable / mv3-brokena corrected manifest.json, complete and valid
workerService workerEvery listener the worker registers, read against an eviction: whether the registration is synchronous at the top level, what top-level state the handler assumed would still be in scope, timers that outlive the idle window, keepalive loops, an async onMessage handler that closes the port before it replies, offscreen-document guards, MV2 APIs that are simply not defined, and onMessageExternal with no sender check.events[]wakes-reliably / wakes-with-gaps / dies-between-eventsthe rewritten worker, or the specific listeners
injectContent scriptsEvery injected script and stylesheet, and what the page pays for it: breadth times timing, a DOM read at document_start, an uncoalesced document-level MutationObserver, one message per loop iteration, the trust boundary with the page, MAIN-world scripts calling chrome.*, worker-only APIs called from a content script, bare element selectors in injected CSS, all_frames, and listeners that accumulate across SPA navigation.scripts[]light-touch / needs-trimming / page-hostilethe trimmed script: observer scoped and coalesced, messages batched
submitStore submissionEvery declared permission and every host pattern, one row each, written as a reviewer reads them: the single purpose as the yardstick, remote code, data handling against what the pasted code actually does, and the permissions that draw a manual review (debugger, nativeMessaging, proxy, management, webRequest, privacy, contentSettings, desktopCapture, broad host access).justifications[]submission-ready / needs-rewrite / will-be-rejectedthe single-purpose statement, the per-permission justification block, the data-usage answers

A lane needs its subject to be in the bundle. The web app hides a lane it cannot honestly run; the API will run it anyway, and the result then says plainly that the file was not pasted rather than reviewing code it was never given. worker without a worker file and inject without a content script are the two that come back thin.

The envelope

Every response has the same shape. Check ok before touching data.

{"ok": true,  "data":  { ... }}
{"ok": false, "error": {"code": "VALIDATION_ERROR", "message": "...", "details": { ... }}}
HTTPerror.codeWhat to do
401UNAUTHORIZEDThe token is missing, malformed or expired. Get a fresh one from the tokens page.
402INSUFFICIENT_CREDITSThe balance is below min_credits. /estimate is free, so check it first.
403FORBIDDENA guest token tried a metered call. /run and /run-stream need a personal token.
404NOT_FOUNDUsually a job id that does not exist, or a mistyped path.
409IDEMPOTENCY_CONFLICTThe same Idempotency-Key was reused with a different body. Change the key or send the original body.
422VALIDATION_ERRORThe input object is the wrong shape. Note that the body is the input object — do not wrap it in an input key.
429RATE_LIMITEDBack off and retry; do not tight-loop.
503UPSTREAM_UNAVAILABLEThe model provider is briefly unavailable. Retry with the same idempotency key.

Input fields

The request body is the input object itself. Do not wrap it in an input key: a wrapped body returns 200 while hiding task from the model, so you silently get whichever lane it guessed.

FieldTypeRequiredMeaning
taskstringrecommendedOne of manifest, worker, inject, submit.
bundlestringyesThe whole extension as one string: manifest.json plus whatever code you have, concatenated. Files are told apart by a marker line — // file: sw.js is the canonical form, and #, ;;, <!-- file: x -->, /* file: x */, == sw.js ==, -- sw.js -- and a fenced ```js sw.js opener are all read the same way. With no marker at all, a single JSON object that has a manifest_version (or a name, permissions, background, content_scripts or action key) is taken as the manifest. Name the files: a content script called watch.js is a content script because the manifest says so, and without the marker nothing can tie the two together.
notesstringnoWhat you are about to do with the extension. The web app always sends this key, empty string included. Short and concrete sharpens the result a lot — “rejected once already for the permission justifications” changes the submit lane more than any other field you can set.
clip_notestringnoSet this when you have truncated the bundle yourself, so the model knows what it is missing. Clip on file boundaries and keep manifest.json whole — half a manifest is worse than no manifest. The web app clips at 60,000 characters: the manifest goes whole, every code file gives up its middle and keeps its head and its tail, each cut carries a marker, and the note says how many characters went from how many files.
prescanobjectnoThe free in-browser reader’s output: facts, flags (each with a stable id), the manifest-keys table and the permissions table. The web app always sends it and the prompt requires one coverage_check entry per flag id. See below — this is the field that decides how accountable the answer is.

What prescan carries, and why sending it matters

The reader is a real Manifest V3 parser that runs in the browser for free: it reads manifest.json the way Chrome’s loader does (so it catches the comment and the trailing comma that make Chrome refuse the extension outright), validates every match pattern, cross-references permissions against chrome.* calls in both directions, and traces the worker’s listener registration. Every finding it makes gets a stable id.

{
  "facts": {
    "manifest":        {"present": true, "manifest_version": 3, "name": "PriceWatch - track any product page and alert on drops",
                        "version": "1.9.3", "description": "...", "default_locale": "",
                        "minimum_chrome_version": "102", "permissions": [...], "host_permissions": [...],
                        "content_scripts": [...], "war": [...], "csp": {...}, "icons": {...},
                        "worker": {"present": true, "path": "sw.js", "type_module": true},
                        "action": {...}, "dnr": {...}, "mv2_keys": [], "unknown_keys": []},
    "json":            {"ok": true, "error": null, "comment_lines": [], "trailing_comma_lines": []},
    "permissions":     {"declared": [...], "optional": [], "sensitive": ["cookies", "history"],
                        "unknown": [], "misplaced": [], "unused": ["bookmarks", "history"], "missing": []},
    "hosts":           {"patterns": ["<all_urls>", "https://shop.example.com/*"], "invalid": [],
                        "broad": ["<all_urls>"], "breadth_max": 100, "count": 2},
    "content_scripts": [{"index": 0, "matches": ["<all_urls>"], "js": ["watch.js"], "css": [],
                         "run_at": "document_idle", "world": "", "all_frames": false,
                         "invalid_matches": [], "broad": true, "breadth": 100, "files_missing": []}],
    "worker":          {"present": true, "path": "sw.js", "type_module": true, "pasted": true,
                        "listener_count": 4, "top_level_listeners": 1,
                        "events": [{"handler": "chrome.alarms.onAlarm", "line": 18,
                                    "registration": "conditional", "enclosing": "registerHandlers",
                                    "survives_restart": true, "async_handler": true,
                                    "returns_true": false, "reply_param": "sendResponse",
                                    "status": "fragile"}],
                        "top_state": [...]},
    "scripts":         [{"path": "sw.js", "role": "service worker", "lines": 74, "listeners": 4},
                        {"path": "watch.js", "role": "content script", "lines": 88, "cost": "heavy"}],
    "apis":            {"used": [{"api": "alarms", "calls": 3, "permission": "alarms", "min_chrome": 0}],
                        "namespaces": ["alarms", "runtime", "storage", "tabs"]},
    "min_chrome":      {"declared": "102", "required": 116, "ok": false, "driver": "chrome.sidePanel"},
    "counts":          {"files": 4, "manifest_keys": 13, "permissions": 11, "hosts": 2,
                        "content_scripts": 1, "war": 1, "listeners": 4, "api_namespaces": 7},
    "flag_counts":     {"blocker": 7, "warn": 9, "note": 5}
  },
  "flags": [{"id":       "SW-06",
             "severity": "blocker" | "warn" | "note",
             "where":    "sw.js",
             "what":     "3 listener(s) are registered inside a function ...",
             "fix":      "Register every listener at the top level of the worker file.",
             "line":     22}],
  "keys":  [{"key": "host_permissions", "status": "present", "value": "[\"<all_urls>\", ...]",
             "verdict": "ok" | "watch" | "broken",
             "rule": "scheme://host/path, path required", "note": "one sentence of why"}],
  "permissions": [{"permission": "cookies",
                   "scope":   "required" | "optional" | "host" | "optional host",
                   "klass":   "silent" | "sensitive" | "broad" | "site" | "invalid" | "misplaced" | "unknown" | "mv2",
                   "warning": "Read and change all your data on all websites",
                   "used":    "yes" | "no" | "unknown" | "n/a",
                   "note":    "high-scrutiny: expect manual review"}]
}

Send it. The prompt’s third house rule is that every id in prescan.flags must appear exactly once in coverage_check, marked confirmed, set-aside or contradicted with a reason — so the flags are what hold the answer to arithmetic it cannot talk its way around, and a contradicted row is a legitimate, useful outcome when the reader has misread your input. Omit prescan and you get a weaker answer and an empty coverage_check: still a real review of the bundle, but nothing anchors it to the deterministic facts, and every count in it becomes something the model asserted rather than something it reconciled.

Flag ids are stable and namespaced by what they are about: MF- manifest keys, MF-J manifest JSON syntax, PM- permissions, HP- host permissions, CM- content-script manifest entries, CP- CSP, WA- web-accessible resources, SW- the worker, CS- content script code, CX- injected CSS, HT- extension pages, DN- declarativeNetRequest, IC- icons, XF- cross-file checks, SC- the secret scan, plus XC-01, MC-01, SP-01, CD-01, PG-01. A rule that fires twice gets #2, #3 appended, so one id never covers two findings.

Output contract

job.output is a JSON string; parse it. Inside is one object: a common envelope shared by all four lanes, plus that lane’s own body key from the table above. The model is asked for a bare object with no prose and no fence around it; the web app still strips a leading fence and slices from the first { to the last } before parsing, which is a cheap two lines worth copying. Everything below is taken from the normaliser the web app itself runs.

{
  "task":           "manifest" | "worker" | "inject" | "submit",
  "title":          string,                     // names the extension and the lane
  "posture":        one of the lane's three values,
  "confidence":     "high" | "medium" | "low",
  "verdict":        string,                     // one or two sentences: the decision and the reason
  "exec_summary":   string,                     // one paragraph a lead can read instead of the result
  "findings":       [{"id":       "MF-001" | "WK-001" | "IN-001" | "SB-001",
                      "severity": "critical" | "high" | "medium" | "low",
                      "area":     one of the lane's areas,
                      "target":   string,       // the manifest key, file:line, permission or listener
                      "title":    string,
                      "evidence": string,       // what in the pasted bundle establishes it
                      "impact":   string,
                      "remedy":   string,       // the exact change, not "consider reviewing"
                      "blocks":   boolean,      // must be fixed before an upload
                      "cites":    [string]}],   // prescan flag ids this finding rests on
  "coverage_check": [{"id":     a prescan flag id,
                      "status": "confirmed" | "set-aside" | "contradicted",
                      "note":   string}],
  "artifacts":      [{"name": string,
                      "language": "json" | "javascript" | "html" | "css" | "markdown" | "csv" | "text",
                      "content": string}],      // complete enough to use; no "..." in the middle
  "assumptions":    [string],
  "open_questions": [string],
  "next_steps":     [string],
  "summary":        string,

  // exactly one of these, matching "task":
  "keys":           [{"key","verdict","required","current","proposed","rule","note"}],
  "events":         [{"handler","trigger","registration","survives_restart","status","state","issue","fix"}],
  "scripts":        [{"script","matches","run_at","world","cost","page_impact","issue","fix"}],
  "justifications": [{"permission","scope","verdict","user_benefit","justification","alternative","note"}]
}

Findings are sorted worst-first, and an id is generated from the lane prefix (MF-, WK-, IN-, SB-, numbered from 001) when one is missing. The four other arrays — assumptions, open_questions, next_steps and every cites list — are plain arrays of strings, and blanks are dropped.

keys[] — the manifest lane

One row per manifest key that matters: every key in the paste there is something to say about, plus every required key that is missing.

FieldTypeMeaning
keystringThe manifest key, for example host_permissions. A row with no key is dropped.
verdictenumok · watch · broken.
requiredbooleanWhether Chrome or the Web Store requires the key at all.
currentstringWhat the pasted manifest has, short.
proposedstringWhat to ship instead, or empty when it is already right.
rulestringThe rule in a few words, for example scheme://host/path, path required.
notestringOne sentence of why.

events[] — the worker lane

One row per listener the worker registers, plus one row with registration: "missing" for a listener the extension plainly needs and does not have.

FieldTypeMeaning
handlerstringFor example chrome.runtime.onMessage. A row with no handler is dropped; event is accepted as an alias for this key.
triggerstringWhat wakes it — a message from the popup, an alarm, an install.
registrationenumtop-level · nested · conditional · missing. Exactly these four, and the reader uses the same four in prescan.facts.worker.events, so echoing its value is safe. The distinction that matters: conditional means the addListener call sits inside a function that is invoked synchronously while the worker file is evaluated — it does re-register on a restart, so it is fragile, not dead. nested is reserved for a registration reachable only from a callback, a then or an await, which genuinely does not survive.
survives_restartbooleanWhether the listener still exists after Chrome has evicted the worker and started it again.
statusenumreliable · fragile · dead.
statestringWhat the handler assumes is still in scope, or none.
issuestringWhat is wrong, empty when nothing is.
fixstringThe change to make.

scripts[] — the inject lane

One row per injected script or stylesheet.

FieldTypeMeaning
scriptstringThe file, for example watch.js. A row with no script is dropped; file is accepted as an alias for this key.
matchesstringThe match patterns it runs on, as one string.
run_atstringdocument_start, document_end or document_idle in practice, but the field is a free string, so read it as text.
worldstringISOLATED or MAIN in practice; also a free string.
costenumlight · medium · heavy — what the page pays.
page_impactstringWhat the page pays for this script, concretely.
issuestringThe defect, empty when there is none.
fixstringThe change to make.

justifications[] — the submit lane

One row per declared permission and per host-permission pattern — every one of them, including the ones that are fine.

FieldTypeMeaning
permissionstringThe permission or the host pattern. A row with no permission is dropped.
scopeenumrequired · optional · host · optional-host.
verdictenumjustified · narrow-it · remove.
user_benefitstringThe one user-visible feature that needs it.
justificationstringThe paragraph to paste into the store form, in the developer’s voice: the feature, then the API, then why a narrower permission does not work.
alternativestringThe narrower permission or API that would do instead, or empty.
notestringReview risk worth knowing, or empty.

Every enum, and what an unrecognised value becomes

The web app coerces rather than throws: an unknown value is replaced by the default in the last column, so a renderer is never handed something it cannot paint. If you consume the API directly, the same coercion is worth copying — it is the difference between a surprising row and a crash.

EnumValuesFallback
taskmanifest · worker · inject · submitthe lane you asked for
confidencehigh · medium · lowmedium
severitycritical · high · medium · lowmedium
coverage_check[].statusconfirmed · set-aside · contradictedset-aside
artifacts[].languagejson · javascript · html · css · markdown · csv · texttext
posture, manifestmv3-clean · mv3-fixable · mv3-brokenmv3-fixable
posture, workerwakes-reliably · wakes-with-gaps · dies-between-eventswakes-with-gaps
posture, injectlight-touch · needs-trimming · page-hostileneeds-trimming
posture, submitsubmission-ready · needs-rewrite · will-be-rejectedneeds-rewrite
area, manifestmanifest-version · permissions · host-permissions · csp · service-worker · content-scripts · action · web-accessible-resources · icons · declarative-net-request · versioning · localisation · cross-browser · deprecated-mv2 · secrets · external-messagingmanifest-version
area, workerlifecycle · event-registration · state · alarms · messaging · offscreen · storage · async · side-panel · permissions · teardownlifecycle
area, injectinjection-timing · match-patterns · dom-cost · observers · isolation · styles · messaging · storage · memory · spa-navigation · frames · trust-boundary · cleanupinjection-timing
area, submitsingle-purpose · permission-justification · remote-code · data-usage · privacy-policy · listing · deceptive-behavior · user-data-handling · review-risk · versioningsingle-purpose
keys[].verdictok · watch · brokenwatch
events[].registrationtop-level · nested · conditional · missingtop-level
events[].statusreliable · fragile · deadfragile
scripts[].costlight · medium · heavymedium
justifications[].scoperequired · optional · host · optional-hostrequired
justifications[].verdictjustified · narrow-it · removenarrow-it

Two aliases are tolerated because the model reaches for them: event for events[].handler and file for scripts[].script. One more sits on findings: flags_cited is read as cites. Booleans are accepted as true or the string "true"; anything else is false.

1. A tiny client helper

Every endpoint below returns the same {ok, data, error} envelope and takes the same two headers, so one small helper covers the whole API. Get your token from the tokens page — no developer console required.

# Every call needs the same two headers. Keep the token out of your shell history:
# read it from a file you control, or paste it into a variable in a subshell.
TOKEN="YOUR_TOKEN"
BASE="https://api.skillsafe.ai/v1/app-api"

call() {  # call <method> <path> [json]
  curl -sS -X "$1" "$BASE/$2" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    ${3:+--data "$3"}
}

2. Check the session and the balance

GET /me tells you whether the token is a guest or a personal session, and what the credit balance is. Do this before a metered call so a shortfall is something you handle rather than a 402 you are surprised by.

call GET me

3. Price a lane for free

POST /estimate costs nothing and starts no job. It returns hold_credits (what will be reserved, priced against the full output cap), min_credits, and the model binding as model, model_alias, markup_bps and sponsor_enabled. The hold differs per lane, because the lanes have different prompts and output caps — estimate the lane you are actually about to run. Send the same body you will send to /run, prescan included: it is part of what is being priced.

# /estimate is FREE and runs no job. It is the honest way to price a lane.
# bundle.txt is manifest.json, sw.js and watch.js concatenated, each behind a
# line reading  // file: <name>
BUNDLE=$(python3 -c 'import json;print(json.dumps(open("bundle.txt").read()))')
call POST estimate "{\"task\":\"manifest\",\"bundle\":$BUNDLE}"

4. Run a lane and poll for it

POST /run is metered and returns a job_id; poll GET /jobs/{id} until status is succeeded or failed. Always send an Idempotency-Key: it makes a retry after a network blip free instead of double-billing. Include the lane in the key — two lanes over one bundle are two distinct runs and must not collide. The web app’s own key is ext-desk:<lane>:<hash of bundle and notes>:a<attempt>, which is worth copying: the attempt counter is what keeps a reformat retry from looking like the first call.

# Metered. The Idempotency-Key makes a retry safe: the same key returns the
# same job instead of billing twice.
KEY="ext-desk:manifest:$(shasum -a 256 bundle.txt | cut -c1-16):a0"
BUNDLE=$(python3 -c 'import json;print(json.dumps(open("bundle.txt").read()))')
JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data "{\"task\":\"manifest\",\"bundle\":$BUNDLE}" \
  | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')

# Poll until terminal.
while :; do
  OUT=$(call GET "jobs/$JOB")
  echo "$OUT" | grep -q '"status":"succeeded"' && break
  echo "$OUT" | grep -q '"status":"failed"' && { echo "$OUT"; exit 1; }
  sleep 2
done
echo "$OUT"

5. Stream it instead

POST /run-stream returns server-sent events. A full lane takes tens of seconds, so streaming lets you show progress. Concatenate every delta, then parse the accumulated text as one JSON object. The same Idempotency-Key rules apply. A JSON reply gives no progress signal of its own, so the web app watches the accumulating buffer for the section keys as they arrive — "posture", "findings", the lane array, "coverage_check", "artifacts" — and that is what moves its progress bar.

# Server-sent events. Each data: line carries a delta; the terminal event
# carries the whole output. Useful because a full lane takes tens of seconds.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data "{\"task\":\"worker\",\"bundle\":$BUNDLE}"

One worked example per lane

The same bundle, four requests. Only task changes — and with it the body key, the posture vocabulary and what the reply is about. The bundle below is an MV3 price watcher that asks for the whole web, keeps its state in the worker’s own scope and holds itself awake with a ping loop; the responses are trimmed, but nothing in them is invented for the page.

task: "manifest" — Manifest and permissions

// request
{"task": "manifest",
 "bundle": "// file: manifest.json\n{\n  \"manifest_version\": 3,\n  \"name\": \"PriceWatch\",\n  \"version\": \"1.9.3\",\n  ...\n}\n\n// file: sw.js\nimport { parsePrice } from './parse.js';\n...",
 "notes": "Rejected once already for the permission justifications.",
 "prescan": {"facts": {...},
             "flags": [{"id": "WA-04", "severity": "warn", "where": "manifest.json", ...},
                       {"id": "XC-01", "severity": "blocker", ...}],
             "keys": [...], "permissions": [...]}}

// response body, abridged
{"task": "manifest", "posture": "mv3-broken", "confidence": "high",
 "verdict": "Two things here cannot be uploaded as they stand: web_accessible_resources publishes
             every file in the extension to every site, and externally_connectable lets any page on
             the internet message the worker.",
 "findings": [{"id": "MF-001", "severity": "critical", "area": "web-accessible-resources",
               "target": "web_accessible_resources[0]", "blocks": true,
               "title": "Stop exposing every file in the extension to every site",
               "evidence": "\"resources\": [\"*\"] with \"matches\": [\"<all_urls>\"]",
               "impact": "Any page can read your source, and can probe for a known file to learn the extension is installed.",
               "remedy": "List the files the page actually needs and match only the sites that need them.",
               "cites": ["WA-04", "WA-05"]},
              {"id": "MF-002", "severity": "high", "area": "host-permissions",
               "target": "host_permissions", "blocks": false,
               "cites": ["HP-02", "HP-03"], ...}],
 "keys": [{"key": "web_accessible_resources", "verdict": "broken", "required": false,
           "current": "[{\"resources\": [\"*\"], \"matches\": [\"<all_urls>\"]}]",
           "proposed": "[{\"resources\": [\"panel.html\"], \"matches\": [\"https://shop.example.com/*\"], \"use_dynamic_url\": true}]",
           "rule": "objects in MV3; matches decides who may load each resource",
           "note": "A wildcard here makes the extension trivially fingerprintable."},
          {"key": "minimum_chrome_version", "verdict": "watch", "required": false,
           "current": "\"102\"", "proposed": "\"116\"",
           "rule": "at least the floor your own API calls need",
           "note": "chrome.sidePanel needs 114 and the code calls it, so 102 installs and then throws."}],
 "coverage_check": [{"id": "WA-04", "status": "confirmed", "note": "The wildcard is in the pasted manifest."},
                    {"id": "XF-02", "status": "set-aside",
                     "note": "bookmarks and history are unused in the pasted code, but popup.js was not pasted, so this is not yet a deletion."}],
 "artifacts": [{"name": "manifest.json", "language": "json", "content": "{\n  \"manifest_version\": 3, ... }"}]}

task: "worker" — Service worker

// request
{"task": "worker", "bundle": "...", "notes": "The alarm seems to stop firing after a while.", "prescan": {...}}

// response body, abridged
{"task": "worker", "posture": "dies-between-events", "confidence": "high",
 "verdict": "Only onInstalled survives a restart. The alarm listener is registered inside
             registerHandlers(), which nothing calls after the first install, so polling stops the
             first time Chrome evicts the worker.",
 "findings": [{"id": "WK-001", "severity": "critical", "area": "event-registration",
               "target": "sw.js:22, inside registerHandlers()", "blocks": true,
               "cites": ["SW-06"], ...}],
 "events": [{"handler": "chrome.alarms.onAlarm",
             "trigger": "the 'poll' alarm",
             "registration": "nested",
             "survives_restart": false,
             "status": "dead",
             "state": "the module-scope `watching` Map, which is empty on every restart",
             "issue": "Registered inside a function, so the listener exists only in the session that happened to call it.",
             "fix": "Move the addListener call to the top level of sw.js and read the watch list out of chrome.storage.session at the start of the handler."},
            {"handler": "chrome.runtime.onInstalled",
             "trigger": "install and update",
             "registration": "top-level", "survives_restart": true, "status": "reliable",
             "state": "none", "issue": "", "fix": ""},
            {"handler": "chrome.runtime.onMessage",
             "trigger": "a message from the popup asking for the current watch list",
             "registration": "missing", "survives_restart": false, "status": "dead",
             "state": "none",
             "issue": "popup.js calls sendMessage and nothing in the worker listens, so the callback never fires.",
             "fix": "Register a synchronous onMessage listener at the top level, return true, and answer from storage."}],
 "coverage_check": [{"id": "SW-06", "status": "confirmed", "note": "Three listeners sit inside registerHandlers()."},
                    {"id": "SW-05", "status": "confirmed", "note": "The 20-second setInterval is a keepalive; delete it."},
                    {"id": "SW-13", "status": "confirmed", "note": "periodInMinutes 0.25 is clamped to 30 seconds in a released extension."}],
 "artifacts": [{"name": "sw.js", "language": "javascript", "content": "// listeners at the top level ..."}]}

A row with registration: "missing" is a listener the extension plainly needs and does not have — it has no line number because there is nothing to point at. Read survives_restart and status together: a dead listener is not broken code, it is code that worked once in the session that ran it and never again.

task: "inject" — Content scripts

// request
{"task": "inject", "bundle": "...", "prescan": {...}}

// response body, abridged
{"task": "inject", "posture": "page-hostile", "confidence": "medium",
 "verdict": "watch.js runs on every site and observes the whole document with no coalescing, so on a
             busy page its callback runs thousands of times a second on the page's own main thread.",
 "findings": [{"id": "IN-001", "severity": "high", "area": "observers",
               "target": "watch.js:31", "blocks": false, "cites": ["CS-02"],
               "remedy": "Observe the price container, set a dirty flag in the callback, and do the parse in a requestIdleCallback.", ...},
              {"id": "IN-002", "severity": "high", "area": "trust-boundary",
               "target": "watch.js:64", "blocks": false, "cites": ["CS-04"], ...}],
 "scripts": [{"script": "watch.js",
              "matches": "<all_urls>",
              "run_at": "document_idle",
              "world": "ISOLATED",
              "cost": "heavy",
              "page_impact": "A document-wide subtree observer plus a sendMessage per mutation: on a page that re-renders a list, that is one IPC hop per row per keystroke, and each one can wake the worker.",
              "issue": "The observer is attached to document.body with subtree: true and no throttle, and the badge is written with innerHTML from page text.",
              "fix": "Narrow the observer to the price node, coalesce into one idle callback, batch the messages behind one port, and build the badge with createElement and textContent."}],
 "coverage_check": [{"id": "CS-02", "status": "confirmed", "note": "Unthrottled document-level observer."},
                    {"id": "CM-06", "status": "contradicted",
                     "note": "The reader calls <all_urls> at document_idle a critical-path cost. It is broad, but idle is the cheap timing; the cost here is the observer, not the injection point."}],
 "artifacts": [{"name": "watch.js", "language": "javascript", "content": "..."}]}

run_at and world come back as free strings, not enums: in practice they are document_start / document_end / document_idle and ISOLATED / MAIN, but a script the manifest does not pin gets whatever the default plainly is, said in words. cost is the field to switch on.

task: "submit" — Store submission

// request
{"task": "submit", "bundle": "...", "notes": "Rejected once already for the permission justifications.", "prescan": {...}}

// response body, abridged
{"task": "submit", "posture": "will-be-rejected", "confidence": "high",
 "verdict": "Three permissions cannot be justified against a price-tracking single purpose, and an
             OAuth client secret is shipped in the manifest of a bundle anyone can unzip.",
 "findings": [{"id": "SB-001", "severity": "critical", "area": "user-data-handling",
               "target": "manifest.json oauth2.client_secret", "blocks": true,
               "cites": ["SC-client-secret"], ...},
              {"id": "SB-002", "severity": "high", "area": "single-purpose",
               "target": "permissions: history, bookmarks", "blocks": false, "cites": ["XF-02"], ...}],
 "justifications": [{"permission": "alarms", "scope": "required", "verdict": "justified",
                     "user_benefit": "Prices are re-checked in the background while the browser is open.",
                     "justification": "We use the alarms permission to re-check the pages a user is
                        watching on a fixed schedule. The extension's worker is evicted between
                        events, so a timer cannot do this; chrome.alarms is the only API that will
                        wake it. No page content leaves the device as part of the check.",
                     "alternative": "", "note": ""},
                    {"permission": "history", "scope": "required", "verdict": "remove",
                     "user_benefit": "None we can find in the pasted code.",
                     "justification": "",
                     "alternative": "Nothing in sw.js or watch.js calls chrome.history.",
                     "note": "A reviewer reads history as browsing-history access. Deleting it removes an install warning, a justification and a review question at once."},
                    {"permission": "<all_urls>", "scope": "host", "verdict": "narrow-it",
                     "user_benefit": "The user can watch a product page on any shop.",
                     "justification": "We request access to the sites the user chooses to watch ...",
                     "alternative": "Move it to optional_host_permissions and request the origin with chrome.permissions.request when the user adds a page.",
                     "note": "Broad host access plus 11 permissions means a manual review and a strict reading of the single purpose."}],
 "coverage_check": [{"id": "SC-client-secret", "status": "confirmed", "note": "It is in the pasted manifest."},
                    {"id": "PM-05", "status": "confirmed", "note": "cookies and history are both high-scrutiny."}],
 "artifacts": [{"name": "single-purpose.txt", "language": "text", "content": "PriceWatch tracks the price of product pages the user chooses ..."},
               {"name": "justifications.md", "language": "markdown", "content": "..."}]}

A remove verdict is the cheapest outcome in this lane, and the one worth reading first. Note also what the strongest possible answer here is: nothing found that would block a submission. The prompt forbids writing anything that reads as an approval, because this is not the Chrome Web Store and a clean result here is not a review outcome.

Costs, and what not to do on a schedule

/me, /estimate and /guest are free and start no job. /run and /run-stream are metered against the caller’s wallet. The hold you see from /estimate prices the full output cap; what you are actually charged is usually far lower and comes back as charged_credits on the finished job.

The holds differ per lane, and by a lot: submit writes a justification paragraph for every permission and manifest writes a whole corrected manifest, so both cap higher than inject over a single content script. Estimate the lane you are about to run, not the one you ran last time. If you wire this into CI, derive the Idempotency-Key from the bundle content and the lane so a re-run on an unchanged bundle costs nothing new, and gate the call on the bundle having actually changed — the free in-browser reader is the thing to run on every commit, not this.

What this app does not do

It reads the text of an extension and returns judgement over it. There is no browser profile, no chrome://extensions, no packer, no CRX signer and no Web Store API behind this endpoint. Nothing is loaded, run, packaged or uploaded. A dead listener is a read of where the addListener call sits in the file you sent; a heavy content script is a read of what its observer and its message pattern will cost, not a measurement. The prompt forbids claiming otherwise and puts what only a real run could settle in open_questions.

Store policy is summarised as it stood when this app was written and is not a substitute for the Chrome Web Store program policies. A submission-ready posture means nothing was found that would block an upload — it is not an approval, and no part of this API can give you one.

Credits

Ext Desk is a derived work built on four agent skills about building browser extensions: @xenitv1/browser-extension (Manifest V3 depth, service-worker persistence through alarms and the offscreen API, the Side Panel API, cross-browser compatibility), @sickn33/chrome-extension-developer (background scripts, service workers, content scripts and cross-context communication), @pproenca/chrome-extension (MV3 performance and code quality for workers, content scripts, message passing and storage) and @davila7/browser-extension-builder (extension architecture, popup UI, monetisation and Chrome Web Store publishing). It is not a republication of those skills, and none of them is being executed here.