Run WP.org Ready from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page parses the readme and the header, scans the code for a fixed set of signal kinds, runs every check, finds every external host, ranks where paid value lives and lays out the stage's plan, then hands it all to the run as facts. The run plans the submission or answers the reviewer; the page then holds it to its own facts (wpreply.js). WP.org Ready is not affiliated with WordPress.org or the WordPress Foundation.
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":"wporg-ready"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required key (task, plugin, readme, header, facts) is missing. The page always sends all eight of task, plugin, readme, header, code, review, carry, facts; review and carry are empty strings for plan. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes, chosen by task, in the order the work runs - the submission, then the review email:
plan- the submission planned: the page's top rung for where paid value lives, one fix per failing check (with its rule and where it lands), one External services entry per host the code calls, the packet items with an owner, and the timeline with a dagger on every figure.reply- the reviewer answered: one fix per point the email raises, in its order, then the reply in the same thread - every point numbered, every fix in one upload, never arguing, never asking to jump the queue. Needs the reviewer's email inreview.
A missing or unknown task is answered as plan. Both lanes send the same eight fields. The facts are the page's checks, one row per line, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the plan's fixes into carry for the reply lane. A theme is out of scope and the page never sends it.
The input
| Field | Required | What |
|---|---|---|
task | yes | plan or reply. If it is missing or neither, do plan and set "task": "plan". |
plugin | yes | the plugin's name and slug, your brand and whether the brand in the name is yours, the submitting account and email domain, the stage, where paid features are enforced, whether paid features must run locally, active installs and whether launch is within 30 days; "not given" means not given. |
readme | yes | the readme.txt as pasted (the page names any cut). |
header | yes | the main plugin file's header comment as pasted. |
code | send it (may be empty) | the PHP or JS that calls the service, as pasted; may be empty. |
review | send it (the email for reply, empty for plan) | reply task only: the reviewer's email as pasted. |
carry | send it (empty for plan) | reply task only: the submission plan's fixes, when the plan ran first; may be empty. |
facts | yes | the page's checks, one line each as rule | id | status | evidence, then the detected hosts, the signals, the paid-value rungs, the stage, the figures and the footnote. A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it. |
The rules the model applies
From the source skill, @samber/wordpress-plugin-directory-playbook - the only standard the run applies:
- Scope: a SaaS vendor's connector plugin - a plugin that talks to the vendor's own service - on the WordPress.org plugin directory. Not themes, not plugin PHP code review in general, not premium plugins sold only off WordPress.org. The volunteer Plugins Team rejects business-model and account choices more often than code: a locked pro feature, an agency's personal account, an undocumented call home.
- Rule 1, no trialware: nothing in the plugin is locked, greyed out, time-limited, quota-capped or sandbox-only until payment. Paid value lives in the service (plan, trial and quota enforced server-side, the plugin only displays them) or in an add-on sold outside WordPress.org. An API key that identifies the account to your service is fine; a key that unlocks local code is not.
- Rule 2, the service is real and documented: the readme names each external service - what it is, what data is sent, when, plus terms and privacy links, your own service included.
- Rule 3, consent covers the service only: installing and configuring a service plugin consents to calls that deliver that service. Install analytics, telemetry or anything unrelated needs a default-off opt-in; count connections at OAuth or registration instead of pinging on activation.
- Rule 4, no remote code in wp-admin: no iframed admin pages, no JS or CSS unrelated to the service from a CDN (fonts excepted), no updates or installs from your servers, no custom update checker. The admin UI ships in the plugin.
- Rule 5, brand-first names belong to the brand owner: name the plugin "<Your brand> <job>" if you own the brand, otherwise "<Job> for <Their brand>"; never start with another brand. Submit from the company's own WordPress.org account with a company-domain email; fix a wrong account by replying to the review email, never by resubmitting. The slug is permanent after approval; keep "wordpress" and "plugin" out of it and never start it with "woo"; the text domain and main file name match the final slug.
- Rule 6, human contact: every committer has 2FA and an email a person reads.
- Rule 7, no credits or claims: "powered by" links in plugin code default off; never imply the plugin guarantees GDPR, PCI or ADA compliance - describe what it assists with.
- Where paid value lives, ranked by efficiency: (1) a directory connector with paid features enforced by your service - the default for a SaaS; (2) a directory plugin plus a premium add-on sold on your site - promoted when paid features must run in PHP or JS on the site; (3) a self-hosted zip only - only when the product needs what the directory bans.
- The review: run Plugin Check first; only one plugin in review per account; answer each review email in the same thread, fixing every point in one upload; no queue jumping except security or legal. A review still incomplete after 3 months† is rejected. Each fix round waits about 10 business days† for a reply; search lists a new plugin 6 to 14 days† after its first commit; an approved plugin with no code for 6 months† can be closed. The queue has swung between near zero and about 1,050† plugins within months.
- Releases: Stable tag is a real tag, never trunk; bump Version for every code change. Every release waits a cooldown of 6 hours† while an automated security review runs; a blocked version is not a closure - fix the findings and ship a new version; reply only for a clear false positive. Release Confirmation offers a 24-hour† delay. The zip stays under 10 MB†. Above 10,000† users, closing or transferring takes a written request to the Plugins Team.
- Mark every figure you quote with † right after it, and close with the footnote: "† verify against the current developer.wordpress.org handbook and make.wordpress.org/plugins; the release process changes often."
House rules
- Use only the input. Never invent a host, a data flow, a URL, a date, an install count or a review outcome; when the input does not say, write "not stated in the input".
- The page's verdicts are settled: the top rung and every check status. Never move them; explain them.
- Every figure from the skill carries † right after it, and the
footnotefield is exactly the skill's footnote. - No URL at all - write "your terms page" or "your privacy page" where a link belongs.
- Write in the language of the input; keep the JSON keys, check ids, rung ids and where ids exactly as given.
- Never use backticks, never write the input's field names as labels in your text, and never copy this prompt's wording into a reply.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
plan
Plan the submission: where paid value lives, one fix per failing check, the External services entries, the packet and the timeline.
summary- one sentence, at most 40 words: what blocks review today and what the plan changes.verdict- an object:rung(exactly the facts' top rung:service,addonorselfhosted),why(one or two sentences from the interview and the checks).fixes- one object per check the facts mark fail, and any warn worth fixing:check(the page's check id),rule(the check's rule, as the facts give it),change(one concrete sentence: what to change in the plugin, the readme, the header or the account),where(readme,header,code,accountorservice). Every fail check appears.services- exactly one object per host the facts list as detected, the host spelled exactly as detected:host,what(the service it is),data(the data sent),when(when it is sent). Write "not stated in the input" for anything the input does not say. No other host.packet- the submission packet items still open or worth confirming, each an object:item(one sentence),owner(a role, not a name),done(true or false).timeline- the steps from here to listed, each an object:step(one sentence),wait(the wait, with † on any figure from the skill).footnote- exactly the skill's footnote.caveats- at most 4 short strings: what the team must confirm;[]when there is nothing to say.
{
"task": "plan",
"summary": "one sentence",
"verdict": { "rung": "service", "why": "one or two sentences" },
"fixes": [ { "check": "stable_tag", "rule": "release", "change": "one sentence", "where": "readme" } ],
"services": [ { "host": "api.service.example", "what": "one phrase", "data": "one phrase", "when": "one phrase" } ],
"packet": [ { "item": "one sentence", "owner": "Engineering", "done": false } ],
"timeline": [ { "step": "one sentence", "wait": "one phrase" } ],
"footnote": "† verify against the current developer.wordpress.org handbook and make.wordpress.org/plugins; the release process changes often.",
"caveats": [ ]
}
The page holds it to its own facts (check() in wpreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL; no backticks; a summary of at most 40 words; every figure from the skill with its dagger and the exact footnote; no host the input does not have; the page's top rung (a different rung is put back and flagged); a fix for every fail check, only the page's check ids, a where of readme, header, code, account or service and a change on each; exactly one services entry per detected host with what, data and when; a packet when the plugin is not yet submitted, an owner on every item; a timeline.
reply
Answer the reviewer: one fix per point the email raises, then the reply in the same thread.
summary- one sentence, at most 40 words: what the reviewer asked and what the upload changes.points- one object per distinct point the email raises, in the email's order:n(1, 2, 3...),reviewer(a short quote or paraphrase of the point, from the email),fix(the change made, one sentence),check(the page's check id the point matches, or ""). When the carried plan has a fix for that check, use it.email- an object:subject("Re: " and the email's subject when it has one),body(the reply text: thanks the reviewer, says the fixed version is uploaded, lists every point as a numbered line with its fix, says every fix is in this one upload; polite, same thread, never argues, never asks to speed up or jump the queue, no URL).footnote- exactly the skill's footnote.caveats- at most 4 short strings: what to confirm before sending;[]when there is nothing to say.
{
"task": "reply",
"summary": "one sentence",
"points": [ { "n": 1, "reviewer": "short quote or paraphrase", "fix": "one sentence", "check": "trialware" } ],
"email": { "subject": "Re: the email's subject", "body": "the reply text, one numbered line per point" },
"footnote": "† verify against the current developer.wordpress.org handbook and make.wordpress.org/plugins; the release process changes often.",
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; the same text rules; at least as many points as the email numbers, only the page's check ids, a fix on each; the subject "Re: " and the email's subject; a body that lists every point as a numbered line, says the fixes are in one upload, never asks to expedite or jump the queue and never argues.
Worked examples
Three runs from the page's free samples (invented plugins, hosts on the reserved .example domain): the submission plan for a CRM forms connector with a locked Pro tab, the reply to its reviewer carrying that plan, and the plan for a payments sync named after another company's brand. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the long fields, strings and lists 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.
plan - A CRM forms connector with a locked Pro tab
The page read it as: fails review as it stands; stage in review; top rung service; detected hosts api.leadwren.example, app.leadwren.example, cdn.jsdelivr.net; fail checks: trialware, services_documented, consent, iframe_admin, remote_assets, account, text_domain, stable_tag.
Input (facts is 5052 characters; the first 1399 are shown):
{
"task": "plan",
"plugin": "Plugin: Leadwren Forms Sync (slug: leadwren-forms-sync)\nOur brand: Leadwren; the brand in the name is ours: yes\nSubmitting account: a personal or agency account; email domain: gmail.com\nStage: in review; paid features enforced: in the plugin; paid features that must run locally in WordPress: no; keep an iframed admin app or own updater as core: no\nActive installs: not live; launch date within 30 days: yes",
"readme": "readme.txt:\n=== Leadwren Forms Sync ===\nContributors: pixelbrambleagency\nTags: crm, forms, leads, contact form, lead capture, sync, marketing, sales\nRequires at least: 6.2\nTested up to: 6.6\nStable tag: trunk\nLicense: GPLv2 or later\nLicense URI: https://www.gnu.org/licenses/gpl-2.0.html\n\nSend every form entry to your Leadwren CRM pipeline and see your lead dashboard right inside your admin.\n\n== Description ==\n\nLeadwren Forms Sync connects your site's forms to Leadwren CRM. Every entry becomes a lead in your pipeline.\n\nThe free version syncs one form. Upgrade to Pro to unlock unlimited forms, field mapping and the lead dashboard.\n\n== Installation ==\n\n1. Upload the plugin and activate it.\n2. Open Leadwren > Settings and paste your Leadwren API key.\n\n== Changelog ==\n\n= 1.0.0 =\n* First release.",
"header": "Main plugin file header:\n<?php\n/**\n * Plugin Name: Leadwren Forms Sync\n * Plugin URI: https://leadwren.example/wordpress\n * Description: Sends form entries to Leadwren CRM.\n * Version: 1.0.0\n * Author: Pixelbramble Agency\n * Author URI: https://pixelbramble.example\n * License: GPLv2 or later\n * Text Domain: leadwren\n * Requires at least: 6.2\n * Requires PHP: 7.4\n */",
"code": "Code excerpts:\nregister_activation_hook( __FILE__, 'lwfs_activate' );\nfunction lwfs_activate() {\n wp_remote_post( 'https://api.leadwren.example/v1/installs', array(\n 'body' => array( 'site' => home_url(), 'wp' => get_bloginfo( 'version' ) ),\n ) );\n}\n\nadd_action( 'admin_menu', function () {\n add_menu_page( 'Leadwren', 'Leadwren', 'manage_options', 'leadwren', 'lwfs_dashboard_page' );\n} );\n\nfunction lwfs_dashboard_page() {\n echo '<iframe src=\"https://app.leadwren.example/embed/dashboard\" width=\"100%\" height=\"800\"></iframe>';\n}\n\nadd_action( 'admin_enqueue_scripts', function () {\n wp_enqueue_script( 'lwfs-chart', 'https://cdn.jsdelivr.net/npm/chart.js', array(), null, true );\n} );\n\nfunction lwfs_settings_tabs() {\n $is_pro = lwfs_is_pro();\n echo '<a class=\"nav-tab' . ( $is_pro ? '' : ' pro-locked' ) . '\" href=\"#mapping\">Field mapping (Pro)</a>';\n if ( ! $is_pro ) {\n echo '<p class=\"description\">Upgrade to Pro to unlock field mapping.</p>';\n }\n}\n\nfunction lwfs_is_pro() {\n return 'valid' === get_option( 'lwfs_license_status' );\n}\n\nadd_action( 'lwfs_form_submitted', function ( $entry ) {\n wp_remote_post( 'https://api.leadwren.example/v1/leads', array(\n 'headers' => array( 'Authorization' => 'Bearer ' . get_option( 'lwfs_api_key' ) ),\n 'body' => wp_json_encode( $entry ),\n ) );\n} );",
"review": "",
"carry": "",
"facts": "The page's checks (8 fail, 2 warn, 12 pass, 2 n/a); verdict: fails review as it stands; rule | id | status | evidence:\n- 1 | trialware | fail | 6 locks in shipped code (code line 21, code line 22, code line 24, code line 28 and 2 more: $is_pro = lwfs_is_pro();) - a key that unlocks local code is trialware; enforce the plan on your service and show its answer; the interview says paid features are enforced in the plugin\n- 2 | services_documented | fail | no External services section in the readme - the code reaches api.leadwren.example, app.leadwren.example, cdn.jsdelivr.net; every service the plugin calls, your own included, is named there\n- 2 | services_links | na | no External services section yet\n- 3 | consent | fail | an activation hook calls home (the callback lwfs_activate(), code line 1) - with no opt-in: install analytics is not the service; count connections at OAuth or registration instead\n- 4 | iframe_admin | fail | an iframe in admin page code (code line 13) - build the settings screen locally and call your API\n- 4 | remote_assets | fail | enqueued from cdn.jsdelivr.net (code line 17) - your admin UI library is not the service and ships in the plugin\n- 4 | update_checker | pass | no custom update checker\n- 5 | name_brand | pass | 'Leadwren Forms Sync' is <Your brand> <job>\n- 5 | account | fail | a personal or agency account - a brand plugin from one gets flagged for\n\n[... 3653 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "plan",
"summary": "A locked Pro tab, an activation ping, an iframed dashboard, a CDN script and an agency gmail account block review; the plan moves enforcement to the Leadwren service and fixes every point in one upload.",
"verdict": {
"rung": "service",
"why": "Leadwren is a SaaS CRM and nothing must run locally, so the plan limits belong on the Leadwren service; the plugin only shows the service's answer. Paid features enforced in the plugin today are trialware on this rung."
},
"fixes": [
{
"check": "trialware",
"rule": "1",
"change": "Remove lwfs_is_pro(), the license status option and the pro-locked Field mapping tab; ship field mapping to every user and let the Leadwren service enforce the plan's form limit and return the message the plugin shows.",
"where": "code"
},
{
"check": "services_documented",
"rule": "2",
"change": "Add an External services section naming api.leadwren.example, the form fields it receives and when, and the terms and privacy links; app.leadwren.example and cdn.jsdelivr.net leave the plugin with the next two fixes.",
"where": "readme"
},
{
"check": "consent",
"rule": "3",
"change": "Delete the activation request to the installs endpoint in lwfs_activate(); count connected sites on the service when an administrator saves a valid API key.",
"where": "code"
},
{
"check": "iframe_admin",
"rule": "4",
"change": "Replace the iframed dashboard in lwfs_dashboard_page() with a settings screen built in the plugin that calls the Leadwren API.",
"where": "code"
},
{
"check": "remote_assets",
"rule": "4",
"change": "Bundle Chart.js inside the plugin and enqueue it from the plugin folder instead of cdn.jsdelivr.net.",
"where": "code"
}
],
"services": [
{
"host": "api.leadwren.example",
"what": "The Leadwren CRM API",
"data": "The submitted form entry, with the API key; on activation today, the site URL and the WordPress version",
"when": "When a form is submitted; the activation call is removed by the consent fix"
},
{
"host": "app.leadwren.example",
"what": "The Leadwren web app, embedded as the admin dashboard",
"data": "not stated in the input",
"when": "When an administrator opens the Leadwren admin page; removed by the iframe fix"
},
{
"host": "cdn.jsdelivr.net",
"what": "A public CDN serving Chart.js to admin pages",
"data": "not stated in the input",
"when": "On admin page loads; removed by bundling Chart.js"
}
],
"packet": [
{
"item": "Plugin Check passes with no error in the Plugin Repo category before the fixed upload.",
"owner": "Engineering",
"done": false
},
{
"item": "The zip stays under 10 MB† with Chart.js bundled, and carries no node_modules, tests or logs.",
"owner": "Engineering",
"done": false
},
{
"item": "Terms and privacy pages are live for the External services section to link.",
"owner": "Product and legal",
"done": false
},
{
"item": "A Leadwren company account with a company-domain email and 2FA on owns the plugin.",
"owner": "Account owner",
"done": false
},
{
"item": "Plugin URI and Author URI are live before the reply.",
"owner": "Web team",
"done": false
}
],
"timeline": [
{
"step": "Upload the fixed zip and reply in the same review thread with every point answered.",
"wait": "about 10 business days† for the reviewer's reply"
},
{
"step": "Answer any further round the same way, all fixes in one upload.",
"wait": "about 10 business days† per round; a review still incomplete after 3 months† is rejected"
},
{
"step": "After approval, commit the code to SVN before launch.",
"wait": "an approved plugin with no code for 6 months† can be closed"
},
{
"step": "The plugin appears in directory search.",
"wait": "6 to 14 days† after the first commit"
}
],
"footnote": "† verify against the current developer.wordpress.org handbook and make.wordpress.org/plugins; the release process changes often.",
"caveats": [
"The launch inside 30 days is at risk: there is no queue jumping, so announce the plugin as coming to WordPress.org if approval misses it.",
"Confirm the company account exists before replying - the reviewer changes the owner only on request."
]
}
Cut from this display only (the saved reply and the check use the full text): fixes (9 items, 5 shown).
What step 5 prints for it: trialware (code) | services_documented (readme) | consent (code) | iframe_admin (code) | remote_assets (code) | account (account) | text_domain (header) | stable_tag (readme) | tags (readme)
The page's check: kept - nothing to flag
reply - A CRM forms connector with a locked Pro tab
The input carries the saved plan (R.laneForm fills carry from the plan reply), as the page's handoff does, and the reviewer's email, which numbers 4 points.
Input (carry is 1560 characters; the first 1397 are shown; facts is 5040 characters; the first 1399 are shown):
{
"task": "reply",
"plugin": "Plugin: Leadwren Forms Sync (slug: leadwren-forms-sync)\nOur brand: Leadwren; the brand in the name is ours: yes\nSubmitting account: a personal or agency account; email domain: gmail.com\nStage: in review; paid features enforced: in the plugin; paid features that must run locally in WordPress: no; keep an iframed admin app or own updater as core: no\nActive installs: not live; launch date within 30 days: yes",
"readme": "readme.txt:\n=== Leadwren Forms Sync ===\nContributors: pixelbrambleagency\nTags: crm, forms, leads, contact form, lead capture, sync, marketing, sales\nRequires at least: 6.2\nTested up to: 6.6\nStable tag: trunk\nLicense: GPLv2 or later\nLicense URI: https://www.gnu.org/licenses/gpl-2.0.html\n\nSend every form entry to your Leadwren CRM pipeline and see your lead dashboard right inside your admin.\n\n== Description ==\n\nLeadwren Forms Sync connects your site's forms to Leadwren CRM. Every entry becomes a lead in your pipeline.\n\nThe free version syncs one form. Upgrade to Pro to unlock unlimited forms, field mapping and the lead dashboard.\n\n== Installation ==\n\n1. Upload the plugin and activate it.\n2. Open Leadwren > Settings and paste your Leadwren API key.\n\n== Changelog ==\n\n= 1.0.0 =\n* First release.",
"header": "Main plugin file header:\n<?php\n/**\n * Plugin Name: Leadwren Forms Sync\n * Plugin URI: https://leadwren.example/wordpress\n * Description: Sends form entries to Leadwren CRM.\n * Version: 1.0.0\n * Author: Pixelbramble Agency\n * Author URI: https://pixelbramble.example\n * License: GPLv2 or later\n * Text Domain: leadwren\n * Requires at least: 6.2\n * Requires PHP: 7.4\n */",
"code": "Code excerpts:\nregister_activation_hook( __FILE__, 'lwfs_activate' );\nfunction lwfs_activate() {\n wp_remote_post( 'https://api.leadwren.example/v1/installs', array(\n 'body' => array( 'site' => home_url(), 'wp' => get_bloginfo( 'version' ) ),\n ) );\n}\n\nadd_action( 'admin_menu', function () {\n add_menu_page( 'Leadwren', 'Leadwren', 'manage_options', 'leadwren', 'lwfs_dashboard_page' );\n} );\n\nfunction lwfs_dashboard_page() {\n echo '<iframe src=\"https://app.leadwren.example/embed/dashboard\" width=\"100%\" height=\"800\"></iframe>';\n}\n\nadd_action( 'admin_enqueue_scripts', function () {\n wp_enqueue_script( 'lwfs-chart', 'https://cdn.jsdelivr.net/npm/chart.js', array(), null, true );\n} );\n\nfunction lwfs_settings_tabs() {\n $is_pro = lwfs_is_pro();\n echo '<a class=\"nav-tab' . ( $is_pro ? '' : ' pro-locked' ) . '\" href=\"#mapping\">Field mapping (Pro)</a>';\n if ( ! $is_pro ) {\n echo '<p class=\"description\">Upgrade to Pro to unlock field mapping.</p>';\n }\n}\n\nfunction lwfs_is_pro() {\n return 'valid' === get_option( 'lwfs_license_status' );\n}\n\nadd_action( 'lwfs_form_submitted', function ( $entry ) {\n wp_remote_post( 'https://api.leadwren.example/v1/leads', array(\n 'headers' => array( 'Authorization' => 'Bearer ' . get_option( 'lwfs_api_key' ) ),\n 'body' => wp_json_encode( $entry ),\n ) );\n} );",
"review": "The reviewer's email:\nSubject: [Plugin Directory] Review in Progress: Leadwren Forms Sync\n\nHello,\n\nThank you for submitting Leadwren Forms Sync. Before we can approve it, the following issues need to be fixed.\n\n1. Trialware: the Field mapping tab is visible but locked until a license is entered, and the readme says Pro unlocks unlimited forms. Plugins may not contain locked or restricted features.\n\n2. Calling home on activation: the plugin sends the site URL and the WordPress version to your server when it is activated, without the user's consent.\n\n3. Undocumented external services: the plugin sends data to api.leadwren.example and loads app.leadwren.example in an iframe, but the readme does not document these services, the data sent or your terms and privacy policy.\n\n4. Remote admin code: the admin dashboard is an iframe of your web app, and Chart.js is loaded from cdn.jsdelivr.net. Admin pages must be built in the plugin and scripts must ship with it.\n\nAlso note that the plugin was submitted from a personal account with a gmail.com address. As the plugin uses the Leadwren brand, it must be owned by an account belonging to Leadwren with an email on the company's domain. Reply to this email to change the owner; do not resubmit.\n\nPlease upload the corrected version on your submission page and reply to this email once every issue is fixed.\n\n--\nPlugin Review Team",
"carry": "The submission plan (service), one fix per failing check:\n- trialware (rule 1, code): Remove lwfs_is_pro(), the license status option and the pro-locked Field mapping tab; ship field mapping to every user and let the Leadwren service enforce the plan's form limit and return the message the plugin shows.\n- services_documented (rule 2, readme): Add an External services section naming api.leadwren.example, the form fields it receives and when, and the terms and privacy links; app.leadwren.example and cdn.jsdelivr.net leave the plugin with the next two fixes.\n- consent (rule 3, code): Delete the activation request to the installs endpoint in lwfs_activate(); count connected sites on the service when an administrator saves a valid API key.\n- iframe_admin (rule 4, code): Replace the iframed dashboard in lwfs_dashboard_page() with a settings screen built in the plugin that calls the Leadwren API.\n- remote_assets (rule 4, code): Bundle Chart.js inside the plugin and enqueue it from the plugin folder instead of cdn.jsdelivr.net.\n- account (rule 5, account): Reply to the review email asking to move the plugin to a Leadwren company account with a company-domain email and 2FA on; do not resubmit.\n- text_domain (rule name, header): Set Text Domain to leadwren-forms-sync and name the main file after the slug.\n- stable_tag (rule release, readme): Set Stable tag to 1.0.0, the Version in the\n\n[... 163 more characters cut from this display ...]",
"facts": "The page's checks (8 fail, 2 warn, 12 pass, 2 n/a); verdict: fails review as it stands; rule | id | status | evidence:\n- 1 | trialware | fail | 6 locks in shipped code (code line 21, code line 22, code line 24, code line 28 and 2 more: $is_pro = lwfs_is_pro();) - a key that unlocks local code is trialware; enforce the plan on your service and show its answer; the interview says paid features are enforced in the plugin\n- 2 | services_documented | fail | no External services section in the readme - the code reaches api.leadwren.example, app.leadwren.example, cdn.jsdelivr.net; every service the plugin calls, your own included, is named there\n- 2 | services_links | na | no External services section yet\n- 3 | consent | fail | an activation hook calls home (the callback lwfs_activate(), code line 1) - with no opt-in: install analytics is not the service; count connections at OAuth or registration instead\n- 4 | iframe_admin | fail | an iframe in admin page code (code line 13) - build the settings screen locally and call your API\n- 4 | remote_assets | fail | enqueued from cdn.jsdelivr.net (code line 17) - your admin UI library is not the service and ships in the plugin\n- 4 | update_checker | pass | no custom update checker\n- 5 | name_brand | pass | 'Leadwren Forms Sync' is <Your brand> <job>\n- 5 | account | fail | a personal or agency account - a brand plugin from one gets flagged for\n\n[... 3641 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "reply",
"summary": "The reviewer raised trialware, an activation call, undocumented services, remote admin code and the agency account; the fixed upload removes the lock, the ping, the iframe and the CDN script and documents the API.",
"points": [
{
"n": 1,
"reviewer": "The Field mapping tab is visible but locked until a license is entered, and the readme says Pro unlocks unlimited forms.",
"fix": "The license check and the locked tab are gone; field mapping works for every user and the Leadwren service enforces the plan's limits, which the plugin only shows.",
"check": "trialware"
},
{
"n": 2,
"reviewer": "The plugin sends the site URL and the WordPress version on activation without consent.",
"fix": "The activation request is removed; nothing is sent until an administrator connects a Leadwren account.",
"check": "consent"
},
{
"n": 3,
"reviewer": "Data goes to api.leadwren.example and app.leadwren.example loads in an iframe, but the readme documents neither service, the data or the terms and privacy policy.",
"fix": "The readme has an External services section naming api.leadwren.example, the form fields sent, when, and links to the terms and privacy pages; the iframe to app.leadwren.example is gone.",
"check": "services_documented"
},
{
"n": 4,
"reviewer": "The admin dashboard is an iframe of the web app and Chart.js loads from cdn.jsdelivr.net.",
"fix": "The dashboard is a settings screen built in the plugin that calls the Leadwren API, and Chart.js ships inside the plugin.",
"check": "iframe_admin"
},
{
"n": 5,
"reviewer": "Submitted from a personal account with a gmail.com address while using the Leadwren brand; reply to change the owner, do not resubmit.",
"fix": "A Leadwren company account with a company-domain email and two-factor authentication is ready to own the plugin.",
"check": "account"
}
],
"email": {
"subject": "Re: [Plugin Directory] Review in Progress: Leadwren Forms Sync",
"body": "Hello,\n\nThank you for the review. The fixed version is uploaded on the submission page and addresses each point:\n\n1. Trialware: the license check and the locked Field mapping tab are removed. Field mapping works for every user, and plan limits are enforced by [... 904 more characters cut from this display]"
},
"footnote": "† verify against the current developer.wordpress.org handbook and make.wordpress.org/plugins; the release process changes often.",
"caveats": [
"Fill in the company account's username before sending.",
"Run Plugin Check on the zip before the upload."
]
}
Cut from this display only (the saved reply and the check use the full text): email.body (1163 characters, 259 shown).
What step 5 prints for it: 1 trialware | 2 consent | 3 services_documented | 4 iframe_admin | 5 account
The page's check: kept - nothing to flag
plan - A payments sync named after another brand
The page read it as: fails review as it stands; stage release blocked; top rung addon; detected hosts fonts.googleapis.com, api.paylark.example; fail checks: services_documented, name_brand, tags, compliance_claim.
Input (facts is 4703 characters; the first 1399 are shown):
{
"task": "plan",
"plugin": "Plugin: Tillgate Payments Sync (slug: tillgate-payments-sync)\nOur brand: Paylark; the brand in the name is ours: no\nSubmitting account: the company account; email domain: paylark.example\nStage: release blocked; paid features enforced: an add-on sold on our site; paid features that must run locally in WordPress: yes; keep an iframed admin app or own updater as core: no\nActive installs: 10,000† or more; launch date within 30 days: no",
"readme": "readme.txt:\n=== Tillgate Payments Sync ===\nContributors: paylark\nTags: tillgate, payments, checkout, pos, point of sale, sync, inventory, orders, refunds, receipts, ecommerce, store, gateway, tillgate alternative\nTested up to: 6.6\nStable tag: 2.4.0\nLicense: GPLv2 or later\nLicense URI: https://www.gnu.org/licenses/gpl-2.0.html\n\nSync Tillgate point-of-sale payments, orders and stock with your store. Makes your store PCI compliant and GDPR compliant out of the box.\n\n== Description ==\n\nTillgate Payments Sync keeps your Tillgate point-of-sale terminal and your online store in step: payments, refunds, orders and stock levels sync both ways through the Paylark service.\n\nPaylark Pro, sold separately on our site, adds offline receipt printing and multi-location stock rules that run inside WordPress.\n\n== External services ==\n\nThis plugin connects to the Paylark API (api.paylark.example) to sync payments, orders and stock with your Tillgate account.\nIt sends order totals, line items, stock levels and the site URL whenever an order changes, and only after an administrator connects a Paylark account.\nPaylark terms of use: https://paylark.example/terms\nPaylark privacy policy: https://paylark.example/privacy\n\n== Changelog ==\n\n= 2.4.0 =\n* Faster stock sync.",
"header": "Main plugin file header:\n<?php\n/**\n * Plugin Name: Tillgate Payments Sync\n * Plugin URI: https://paylark.example/tillgate-sync\n * Description: Syncs Tillgate point-of-sale payments, orders and stock through Paylark.\n * Version: 2.4.0\n * Requires at least: 6.3\n * Requires PHP: 8.0\n * Author: Paylark\n * Author URI: https://paylark.example\n * License: GPLv2 or later\n * Text Domain: tillgate-payments-sync\n */",
"code": "Code excerpts:\nadd_action( 'admin_enqueue_scripts', function () {\n wp_enqueue_style( 'tgps-font', 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600', array(), null );\n} );\n\nadd_action( 'woocommerce_order_status_changed', function ( $order_id ) {\n $order = wc_get_order( $order_id );\n wp_remote_post( 'https://api.paylark.example/v1/orders/sync', array(\n 'headers' => array( 'Authorization' => 'Bearer ' . get_option( 'tgps_api_key' ) ),\n 'body' => wp_json_encode( array( 'id' => $order_id, 'total' => $order->get_total() ) ),\n ) );\n} );\n\nfunction tgps_send_usage_stats() {\n if ( 'yes' !== get_option( 'tgps_allow_tracking', 'no' ) ) {\n return;\n }\n wp_remote_post( 'https://api.paylark.example/v1/telemetry', array( 'body' => array( 'site' => home_url() ) ) );\n}\n\nadd_action( 'admin_post_tgps_save_settings', function () {\n update_option( 'tgps_api_key', sanitize_text_field( wp_unslash( $_POST['tgps_api_key'] ) ) );\n wp_safe_redirect( admin_url( 'options-general.php?page=tillgate' ) );\n exit;\n} );",
"review": "",
"carry": "",
"facts": "The page's checks (4 fail, 2 warn, 17 pass, 1 n/a); verdict: fails review as it stands; rule | id | status | evidence:\n- 1 | trialware | pass | no license lock, trial or local quota in the code; paid features an add-on sold on our site\n- 2 | services_documented | fail | the section \"External services\" does not name: fonts.googleapis.com (fonts are excepted from the CDN rule, not from the disclosure - name the font service or ship the font)\n- 2 | services_links | pass | terms and privacy both named\n- 3 | consent | warn | telemetry at code line 13, code line 17 - an opt-in is present (code line 14); confirm it defaults off and nothing is sent before it is ticked\n- 4 | iframe_admin | pass | no iframe in admin code\n- 4 | remote_assets | pass | only fonts load remotely (fonts.googleapis.com) - fonts are excepted\n- 4 | update_checker | pass | no custom update checker\n- 5 | name_brand | fail | 'Tillgate Payments Sync' starts with another company's brand (interview) - name it '<Job> for <Their brand>'; a brand-first name belongs to the brand owner\n- 5 | account | pass | company account, email at paylark.example\n- name | slug_words | pass | 'tillgate-payments-sync'\n- name | text_domain | pass | Text Domain: tillgate-payments-sync\n- name | name_match | pass | both read 'Tillgate Payments Sync'\n- name | headers_complete | pass | Plugin URI, Author URI, License and License URI are all set\n\n[... 3304 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "plan",
"summary": "Version 2.4.0 is blocked and the plugin leads with another company's brand, claims PCI and GDPR compliance and lists 14 tags; the plan fixes the handler and the readme and ships a new version.",
"verdict": {
"rung": "addon",
"why": "Offline receipt printing and multi-location stock rules must run inside WordPress, so they belong in Paylark Pro sold on your site; the free plugin may link to it but never install it or hold its code."
},
"fixes": [
{
"check": "",
"rule": "release",
"change": "Add a nonce check and a manage_options capability check to the admin_post_tgps_save_settings handler, then ship a new version rather than appealing the block.",
"where": "code"
},
{
"check": "name_brand",
"rule": "5",
"change": "Rename the display name to Payments Sync for Tillgate in the main file header and the readme title; the slug and text domain stay tillgate-payments-sync.",
"where": "header"
},
{
"check": "compliance_claim",
"rule": "7",
"change": "Delete the sentence that makes the store PCI compliant and GDPR compliant; describe what the plugin syncs and what it sends instead.",
"where": "readme"
},
{
"check": "tags",
"rule": "rank",
"change": "Cut the tags to five: payments, point of sale, inventory, orders, refunds.",
"where": "readme"
},
{
"check": "tags_brands",
"rule": "rank",
"change": "Drop tillgate and tillgate alternative from the tags.",
"where": "readme"
}
],
"services": [
{
"host": "fonts.googleapis.com",
"what": "Google Fonts, serving the Inter font to admin pages",
"data": "not stated in the input",
"when": "On admin page loads"
},
{
"host": "api.paylark.example",
"what": "The Paylark API",
"data": "Order totals, line items, stock levels and the site URL; the site URL for usage statistics once tracking is allowed",
"when": "Whenever an order changes, after an administrator connects a Paylark account; usage statistics only after the opt-in is ticked"
}
],
"packet": [
{
"item": "Plugin Check passes, Security category included, before the new tag.",
"owner": "Engineering",
"done": false
},
{
"item": "Version bumped in the main file and Stable tag set to the new tag.",
"owner": "Engineering",
"done": false
},
{
"item": "Release Confirmation turned on for the account.",
"owner": "Account owner",
"done": false
}
],
"timeline": [
{
"step": "Fix the handler and the readme, then commit trunk and copy it to the new tag.",
"wait": "a cooldown of 6 hours† while the automated security review runs"
},
{
"step": "Sites move off the blocked 2.4.0.",
"wait": "sites keep the previous version until a new release passes; the listing stays up"
},
{
"step": "Reply to the email once the new version is out.",
"wait": "an appeal by email is slower than a fixed release"
},
{
"step": "For a risky release, hold automatic updates with Release Confirmation.",
"wait": "a 24-hour† delay on automatic updates"
}
],
"footnote": "† verify against the current developer.wordpress.org handbook and make.wordpress.org/plugins; the release process changes often.",
"caveats": [
"Above 10,000† installs, closing or transferring the plugin takes a written request to the Plugins Team - never close it to pause.",
"The slug cannot change after approval, so the new display name and the old slug will differ."
]
}
Cut from this display only (the saved reply and the check use the full text): fixes (7 items, 5 shown).
What step 5 prints for it: no page check (code) | name_brand (header) | compliance_claim (readme) | tags (readme) | tags_brands (readme) | services_documented (code) | consent (code)
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./wpkit.js"); require("./wpreply.js")), then:
const K = WpKit, R = WpReply;
const f = K.form(Object.assign(K.blank(), { brand: "Acme", domain: "gmail.com", stage: "notsub", paid: "plugin",
readme: "=== Acme Forms Sync ===\nStable tag: trunk\n\nSends form entries to Acme.",
header: "/*\n * Plugin Name: Acme Forms Sync\n * Version: 1.0.0\n */",
code: "wp_remote_post( 'https://api.acme.example/v1/entries', $args );" }));
// f.rows (the checks), f.signals, f.hosts, f.ladder (where paid value lives), f.plan (the stage's plan)
// K.checksCsv(f), K.readmeSkeleton(f), K.board(f) - the exports
const b = K.buildInput(f, "plan"); // or "reply" (needs review; R.laneForm(form, "reply", planReply) fills carry)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "plan");
// R.check(reply, b) // {status: kept or flagged, notes, fixed}
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://wporg-ready.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://wporg-ready.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// wporg-ready.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://wporg-ready.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://wporg-ready.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// WporgReady.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class WporgReady {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://wporg-ready.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://wporg-ready.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://wporg-ready.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://wporg-ready.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"wporg-ready"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "wporg-ready"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "wporg-ready" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "wporg-ready"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "wporg-ready"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "wporg-ready" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "wporg-ready"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "wporg-ready" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "plan", "plugin": "Plugin: Leadwren Forms Sync (slug: leadwren-forms-sync)
Our brand: ...", "readme": "readme.txt:
=== Leadwren Forms Sync ===
...", "header": "Main plugin file header:
...", "code": "Code excerpts:
...", "review": "", "carry": "", "facts": "The page's checks (...); verdict: fails review as it stands; rule | id | status | evidence:
- 1 | trialware | fail | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "plan", "plugin": "Plugin: Leadwren Forms Sync (slug: leadwren-forms-sync)
Our brand: ...", "readme": "readme.txt:
=== Leadwren Forms Sync ===
...", "header": "Main plugin file header:
...", "code": "Code excerpts:
...", "review": "", "carry": "", "facts": "The page's checks (...); verdict: fails review as it stands; rule | id | status | evidence:
- 1 | trialware | fail | ..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "plan" or "reply", plugin, readme, header, code, review, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses wporg-ready:<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 plan reply carries summary, verdict, fixes, services, packet, timeline, footnote and caveats; a reply-lane reply carries summary, points, email, footnote and caveats. The code below prints the gist of either: each fix of the plan, or each reviewer point with its fix.
KEY="wporg-ready:$(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 == "reply" then (.points[] | "\(.n). \(.reviewer) -> \(.fix)")
else (.fixes[] | "\(.check) [\(.where)] \(.change)") end' reply.json
import hashlib, time
key = "wporg-ready:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "reply":
for x in reply["points"]:
print(x["n"], x["reviewer"], "->", x["fix"])
print(reply["email"]["subject"])
else:
for x in reply["fixes"]:
print(x["check"], x["where"], x["change"])
import { createHash } from "node:crypto";
const key = `wporg-ready:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "reply") { for (const x of reply.points) console.log(x.n, x.reviewer, "->", x.fix); console.log(reply.email.subject); }
else { for (const x of reply.fixes) console.log(x.check, x.where, x.change); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "wporg-ready:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
key, name, field := "fixes", "check", "change"
if reply["task"] == "reply" {
key, name, field = "points", "reviewer", "fix"
}
ls, _ := reply[key].([]any)
for _, x := range ls {
xx := x.(map[string]any)
fmt.Println(xx[name], xx[field])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "wporg-ready:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("reply")) {
for (JsonNode x : reply.path("points")) System.out.println(x.path("reviewer").asText() + " -> " + x.path("fix").asText());
} else {
for (JsonNode x : reply.path("fixes")) System.out.println(x.path("check").asText() + " " + x.path("change").asText());
}
require "digest"
key = "wporg-ready:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "reply"
reply["points"].each { |x| puts "#{x["reviewer"]} -> #{x["fix"]}" }
else
reply["fixes"].each { |x| puts "#{x["check"]} #{x["change"]}" }
end
$key = "wporg-ready:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "reply") { foreach ($reply["points"] as $x) echo $x["reviewer"], " -> ", $x["fix"], "\n"; }
else { foreach ($reply["fixes"] as $x) echo $x["check"], " ", $x["change"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"wporg-ready:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "reply") { foreach (var x in reply["points"]!.AsArray()) Console.WriteLine($"{x!["reviewer"]} -> {x["fix"]}"); }
else { foreach (var x in reply["fixes"]!.AsArray()) Console.WriteLine($"{x!["check"]} {x["change"]}"); }
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: wporg-ready:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "plan", "plugin": "Plugin: Leadwren Forms Sync (slug: leadwren-forms-sync)
Our brand: ...", "readme": "readme.txt:
=== Leadwren Forms Sync ===
...", "header": "Main plugin file header:
...", "code": "Code excerpts:
...", "review": "", "carry": "", "facts": "The page's checks (...); verdict: fails review as it stands; rule | id | status | evidence:
- 1 | trialware | fail | ..."}
# - see the input table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "wporg-ready:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `wporg-ready:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "wporg-ready:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "wporg-ready:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "wporg-ready:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: wporg-ready:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"wporg-ready:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);