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.
task | Lane | What it reads | Body key | posture | Artifacts |
|---|---|---|---|---|---|
manifest | Manifest and permissions | Every 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-broken | a corrected manifest.json, complete and valid |
worker | Service worker | Every 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-events | the rewritten worker, or the specific listeners |
inject | Content scripts | Every 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-hostile | the trimmed script: observer scoped and coalesced, messages batched |
submit | Store submission | Every 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-rejected | the 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": { ... }}}
| HTTP | error.code | What to do |
|---|---|---|
| 401 | UNAUTHORIZED | The token is missing, malformed or expired. Get a fresh one from the tokens page. |
| 402 | INSUFFICIENT_CREDITS | The balance is below min_credits. /estimate is free, so check it first. |
| 403 | FORBIDDEN | A guest token tried a metered call. /run and /run-stream need a personal token. |
| 404 | NOT_FOUND | Usually a job id that does not exist, or a mistyped path. |
| 409 | IDEMPOTENCY_CONFLICT | The same Idempotency-Key was reused with a different body. Change the key or send the original body. |
| 422 | VALIDATION_ERROR | The input object is the wrong shape. Note that the body is the input object — do not wrap it in an input key. |
| 429 | RATE_LIMITED | Back off and retry; do not tight-loop. |
| 503 | UPSTREAM_UNAVAILABLE | The 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | recommended | One of manifest, worker, inject, submit. |
bundle | string | yes | The 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. |
notes | string | no | What 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_note | string | no | Set 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. |
prescan | object | no | The 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.
| Field | Type | Meaning |
|---|---|---|
key | string | The manifest key, for example host_permissions. A row with no key is dropped. |
verdict | enum | ok · watch · broken. |
required | boolean | Whether Chrome or the Web Store requires the key at all. |
current | string | What the pasted manifest has, short. |
proposed | string | What to ship instead, or empty when it is already right. |
rule | string | The rule in a few words, for example scheme://host/path, path required. |
note | string | One 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.
| Field | Type | Meaning |
|---|---|---|
handler | string | For example chrome.runtime.onMessage. A row with no handler is dropped; event is accepted as an alias for this key. |
trigger | string | What wakes it — a message from the popup, an alarm, an install. |
registration | enum | top-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_restart | boolean | Whether the listener still exists after Chrome has evicted the worker and started it again. |
status | enum | reliable · fragile · dead. |
state | string | What the handler assumes is still in scope, or none. |
issue | string | What is wrong, empty when nothing is. |
fix | string | The change to make. |
scripts[] — the inject lane
One row per injected script or stylesheet.
| Field | Type | Meaning |
|---|---|---|
script | string | The file, for example watch.js. A row with no script is dropped; file is accepted as an alias for this key. |
matches | string | The match patterns it runs on, as one string. |
run_at | string | document_start, document_end or document_idle in practice, but the field is a free string, so read it as text. |
world | string | ISOLATED or MAIN in practice; also a free string. |
cost | enum | light · medium · heavy — what the page pays. |
page_impact | string | What the page pays for this script, concretely. |
issue | string | The defect, empty when there is none. |
fix | string | The 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.
| Field | Type | Meaning |
|---|---|---|
permission | string | The permission or the host pattern. A row with no permission is dropped. |
scope | enum | required · optional · host · optional-host. |
verdict | enum | justified · narrow-it · remove. |
user_benefit | string | The one user-visible feature that needs it. |
justification | string | The 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. |
alternative | string | The narrower permission or API that would do instead, or empty. |
note | string | Review 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.
| Enum | Values | Fallback |
|---|---|---|
task | manifest · worker · inject · submit | the lane you asked for |
confidence | high · medium · low | medium |
severity | critical · high · medium · low | medium |
coverage_check[].status | confirmed · set-aside · contradicted | set-aside |
artifacts[].language | json · javascript · html · css · markdown · csv · text | text |
posture, manifest | mv3-clean · mv3-fixable · mv3-broken | mv3-fixable |
posture, worker | wakes-reliably · wakes-with-gaps · dies-between-events | wakes-with-gaps |
posture, inject | light-touch · needs-trimming · page-hostile | needs-trimming |
posture, submit | submission-ready · needs-rewrite · will-be-rejected | needs-rewrite |
area, manifest | manifest-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-messaging | manifest-version |
area, worker | lifecycle · event-registration · state · alarms · messaging · offscreen · storage · async · side-panel · permissions · teardown | lifecycle |
area, inject | injection-timing · match-patterns · dom-cost · observers · isolation · styles · messaging · storage · memory · spa-navigation · frames · trust-boundary · cleanup | injection-timing |
area, submit | single-purpose · permission-justification · remote-code · data-usage · privacy-policy · listing · deceptive-behavior · user-data-handling · review-risk · versioning | single-purpose |
keys[].verdict | ok · watch · broken | watch |
events[].registration | top-level · nested · conditional · missing | top-level |
events[].status | reliable · fragile · dead | fragile |
scripts[].cost | light · medium · heavy | medium |
justifications[].scope | required · optional · host · optional-host | required |
justifications[].verdict | justified · narrow-it · remove | narrow-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"}
}
import json, urllib.request
TOKEN = "YOUR_TOKEN" # from https://ext-desk.skillsafe.ai/tokens.html
BASE = "https://api.skillsafe.ai/v1/app-api"
def call(method, path, payload=None):
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
body = json.load(r)
if not body.get("ok"):
raise RuntimeError(body.get("error"))
return body["data"]
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
async function call(method, path, payload) {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: payload === undefined ? undefined : JSON.stringify(payload)
});
const body = await res.json();
if (!body.ok) throw new Error(JSON.stringify(body.error));
return body.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
// Read it from the environment with os.Getenv, or paste it here for a one-off.
var token = os.Getenv("SKILLSAFE_TOKEN")
const base = "https://api.skillsafe.ai/v1/app-api"
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error json.RawMessage `json:"error"`
}
func call(method, path string, payload any) (json.RawMessage, error) {
var body io.Reader
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s", env.Error)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class ExtDesk {
static final String TOKEN = "YOUR_TOKEN"; // from /tokens.html
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String method, String path, String json) throws Exception {
HttpRequest.BodyPublisher body = (json == null)
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(json);
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, body)
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return res.body(); // {"ok":true,"data":{...}} — check ok before using data
}
}
require "json"
require "net/http"
TOKEN = "YOUR_TOKEN" # from /tokens.html
BASE = URI("https://api.skillsafe.ai/v1/app-api")
def call(method, path, payload = nil)
uri = URI.join(BASE.to_s + "/", path.sub(%r{^/}, ""))
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(payload) if payload
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
body = JSON.parse(res.body)
raise body["error"].to_s unless body["ok"]
body["data"]
end
<?php
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
const BASE = "https://api.skillsafe.ai/v1/app-api";
function call(string $method, string $path, ?array $payload = null) {
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
],
]);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$body = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($body["ok"])) {
throw new RuntimeException(json_encode($body["error"] ?? null));
}
return $body["data"];
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
class ExtDesk {
const string Token = "YOUR_TOKEN"; // from /tokens.html
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new HttpClient();
static async Task<JsonElement> Call(HttpMethod method, string path, object? payload = null) {
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (payload is not null) {
req.Content = new StringContent(
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
}
var res = await Http.SendAsync(req);
var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
if (!doc.RootElement.GetProperty("ok").GetBoolean()) {
throw new Exception(doc.RootElement.GetProperty("error").ToString());
}
return doc.RootElement.GetProperty("data");
}
}
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
me = call("GET", "/me")
print(me["type"], me.get("credits"))
const me = await call("GET", "/me");
console.log(me.type, me.credits);
data, err := call("GET", "/me", nil)
if err != nil {
panic(err)
}
fmt.Println(string(data))
System.out.println(call("GET", "/me", null));
me = call("GET", "/me")
puts "#{me["type"]} #{me["credits"]}"
$me = call("GET", "/me");
echo $me["type"], " ", $me["credits"] ?? "n/a", PHP_EOL;
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("type"));
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}"
payload = {
"task": "manifest",
"bundle": open("bundle.txt").read(),
"notes": "Uploading the MV3 version this week; it still loads unpacked on an old Chrome."
}
est = call("POST", "/estimate", payload)
print(est["model"], est["model_alias"], est["markup_bps"])
print("reserved:", est["hold_credits"], "minimum:", est["min_credits"])
const payload = {
task: "manifest",
bundle: bundleText,
notes: "Rejected once already for the permission justifications."
};
const est = await call("POST", "/estimate", payload);
console.log(est.model, est.hold_credits);
payload := map[string]any{
"task": "manifest",
"bundle": bundleText,
}
data, err := call("POST", "/estimate", payload)
if err != nil {
panic(err)
}
fmt.Println(string(data))
String payload = """
{"task":"manifest","bundle":%s}
""".formatted(jsonQuote(bundleText));
System.out.println(call("POST", "/estimate", payload));
est = call("POST", "/estimate", {
"task" => "manifest",
"bundle" => File.read("bundle.txt")
})
puts "#{est["model"]} reserves #{est["hold_credits"]}"
$est = call("POST", "/estimate", [
"task" => "manifest",
"bundle" => file_get_contents("bundle.txt"),
]);
echo $est["model"], " reserves ", $est["hold_credits"], PHP_EOL;
var est = await Call(HttpMethod.Post, "/estimate", new {
task = "manifest",
bundle = File.ReadAllText("bundle.txt")
});
Console.WriteLine(est.GetProperty("hold_credits"));
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"
import hashlib, time
bundle = open("bundle.txt").read()
key = "ext-desk:manifest:%s:a0" % hashlib.sha256(bundle.encode()).hexdigest()[:16]
req = urllib.request.Request(BASE + "/run",
data=json.dumps({"task": "manifest", "bundle": bundle}).encode(),
method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
job = json.load(urllib.request.urlopen(req))["data"]["job_id"]
while True:
j = call("GET", "/jobs/" + job)
if j["status"] in ("succeeded", "failed"):
break
time.sleep(2)
review = json.loads(j["output"])
print(review["posture"], "-", review["verdict"])
for k in review["keys"]:
print(k["verdict"], k["key"], "->", k["proposed"])
const key = `ext-desk:manifest:${hash16(bundleText)}:a0`;
const res = await fetch(BASE + "/run", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify({ task: "manifest", bundle: bundleText })
});
const { data } = await res.json();
let job;
do {
await new Promise(r => setTimeout(r, 2000));
job = await call("GET", `/jobs/${data.job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
const review = JSON.parse(job.output);
console.log(review.posture, review.verdict);
b, _ := json.Marshal(map[string]any{
"task": "manifest", "bundle": bundleText,
})
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "ext-desk:manifest:"+hash16(bundleText)+":a0")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
// decode {"ok":true,"data":{"job_id":"..."}} then poll GET /jobs/{id}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "ext-desk:manifest:" + hash16(bundleText) + ":a0")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
String created = HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
// read data.job_id, then poll GET /jobs/{id} until status is terminal
uri = URI.join(BASE.to_s + "/", "run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "ext-desk:manifest:#{Digest::SHA256.hexdigest(bundle)[0, 16]}:a0"
req.body = JSON.dump({ "task" => "manifest", "bundle" => bundle })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("GET", "/jobs/#{job_id}")
break puts JSON.parse(job["output"])["verdict"] if job["status"] == "succeeded"
raise job.to_s if job["status"] == "failed"
sleep 2
end
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: ext-desk:manifest:" . substr(hash("sha256", $bundle), 0, 16) . ":a0",
],
CURLOPT_POSTFIELDS => json_encode(["task" => "manifest", "bundle" => $bundle]),
]);
$jobId = json_decode(curl_exec($ch), true)["data"]["job_id"];
curl_close($ch);
do {
sleep(2);
$job = call("GET", "/jobs/" . $jobId);
} while (!in_array($job["status"], ["succeeded", "failed"], true));
$review = json_decode($job["output"], true);
echo $review["posture"], ": ", $review["verdict"], PHP_EOL;
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", $"ext-desk:manifest:{Hash16(bundle)}:a0");
req.Content = new StringContent(
JsonSerializer.Serialize(new { task = "manifest", bundle }),
Encoding.UTF8, "application/json");
var created = JsonDocument.Parse(
await (await Http.SendAsync(req)).Content.ReadAsStringAsync());
var jobId = created.RootElement.GetProperty("data").GetProperty("job_id").GetString();
// then poll GET /jobs/{jobId} until status is terminal
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}"
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps({"task": "worker", "bundle": bundle}).encode(),
method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
buf = ""
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().strip()
if line.startswith("data:"):
chunk = line[5:].strip()
if chunk and chunk != "[DONE]":
buf += json.loads(chunk).get("delta", "")
review = json.loads(buf[buf.index("{"):buf.rindex("}") + 1])
for e in review["events"]:
print(e["status"], e["handler"], e["registration"])
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify({ task: "worker", bundle: bundleText })
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
for (const line of dec.decode(value).split("\n")) {
if (!line.startsWith("data:")) continue;
const chunk = line.slice(5).trim();
if (chunk && chunk !== "[DONE]") buf += (JSON.parse(chunk).delta || "");
}
}
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
var buf bytes.Buffer
for sc.Scan() {
line := sc.Text()
if after, ok := strings.CutPrefix(line, "data:"); ok {
// unmarshal {"delta":"..."} and append
_ = after
}
}
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HTTP.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> appendDelta(l.substring(5).trim()));
uri = URI.join(BASE.to_s + "/", "run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "task" => "worker", "bundle" => bundle })
buf = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |seg|
seg.each_line do |line|
next unless line.start_with?("data:")
chunk = line[5..].strip
buf << (JSON.parse(chunk)["delta"] || "") unless chunk.empty? || chunk == "[DONE]"
end
end
end
end
$buf = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(["task" => "worker", "bundle" => $bundle]),
CURLOPT_WRITEFUNCTION => function ($ch, $seg) use (&$buf) {
foreach (explode("\n", $seg) as $line) {
if (str_starts_with($line, "data:")) {
$chunk = trim(substr($line, 5));
if ($chunk !== "" && $chunk !== "[DONE]") {
$buf .= json_decode($chunk, true)["delta"] ?? "";
}
}
}
return strlen($seg);
},
]);
curl_exec($ch);
curl_close($ch);
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var buf = new StringBuilder();
while (await reader.ReadLineAsync() is string line) {
if (!line.StartsWith("data:")) continue;
var chunk = line[5..].Trim();
if (chunk.Length > 0 && chunk != "[DONE]") {
buf.Append(JsonDocument.Parse(chunk).RootElement
.GetProperty("delta").GetString());
}
}
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.