Run Listing Rulebook from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads every draft rule as objective or principles-only, checks the five rulebook gates, ranks the rungs from the interview, sets the video ceiling and lints the sample listing for the named triggers, then hands it all to the run as facts. The run writes the rulebook or the listing review; the page then holds it to its own facts (lrreply.js).
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"listing-rulebook"}), 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, market, draft, facts) is missing. The page always sends all five of task, market, draft, carry, facts; carry is an empty string for rulebook. |
| 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 standard, then one submission:
rulebook- the published rulebook: the six rungs as ranked, every core field with a self-checkable reject criterion, one geometry per asset, the nine rejection triggers, the category cap, staleness and badge rules, and the notice runway.review- the review of the sample listing: accept or reject, every violation the page found plus any judgment finding, each with its field and the fix.
A missing or unknown task is answered as rulebook. Both lanes send the same five 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 rulebook's decisions into carry for the review lane.
The input
| Field | Required | What |
|---|---|---|
task | yes | rulebook or review. If it is missing or neither, do rulebook and set "task": "rulebook". |
market | yes | the marketplace, the platform's name and the interview: stage, volume, who maintains listings, curation, who evaluates, releases, languages, badges, deadline, mandate, effort and the assets already owned; "not given" means not given. |
draft | yes | the draft rules as the page read them (each field's rule and limit, principles-only rows marked), the media rows, the video ceiling, the category cap, the keyword repetition count, the decay and badge rows, and the sample listing when one is given. |
carry | send it (empty for rulebook) | review task only: the rulebook's decisions, when the rulebook task ran first; may be empty. |
facts | yes | the page's checks, one line each as area | id | status | evidence, and the ranked rungs. 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/app-marketplace-listing-standards - the only standard the run applies:
- The rulebook exists so a submitter can self-check before submitting and every reviewer rejects the same listings. Content standards stay separate from security review: a blurry screenshot or a keyword-stuffed description is rejected regardless of whether the app is safe.
- Every marketplace converges on one skeleton: a short hook, a benefit-oriented body, a fixed-geometry gallery, a capped optional video, a capped category assignment, and off-listing trust surfaces (landing page, support channel, privacy policy) that gate approval on their own. Strictness and tone follow who maintains listings and who evaluates them.
- Field-spec strictness, ranked by efficiency: named rejection triggers plus exact limits on the load-bearing fields (name, hook, body, media geometry) by default; exact limits on every field once vendors self-maintain at volume; a completion-tracked builder only when a partner console already exists. Principles-only rules are deleted, never parked: two reviewers reading the same principle reject different listings.
- Media: there is no industry-standard screenshot geometry - pick one per asset type and publish it. Default: a fixed-geometry hard spec plus content rules (at least one screenshot of the real app UI, no PII, no pricing or review claims in the image, a wrong-ratio or off-topic image is a named trigger). Content-correctness first only while the operator maintains listings. Video ceiling from who evaluates: 30-second function proof with marketing footage banned for diligence buyers, 2-3 minutes with promotional tone for self-serve installs, captions either way. Accessibility floor: alt text, captioned video, WCAG 2.2 AA contrast.
- The description leads with the customer outcome, not the technical mechanism, and ties each feature claim to a measurable result. Rejection triggers, each self-checkable: keyword spam defined by a repetition count; unattributed or anonymous testimonials and unsubstantiated guarantees; trademark naming order ("Task Tracker for {Platform}" accepted, "{Platform} Task Tracker" rejected, with a published pair); misrepresentation, including undisclosed third-party dependencies and their cost; a public landing page (a PDF or a code repository is not one), a support channel with a response commitment, and a privacy policy meeting a coverage checklist as hard gates.
- Taxonomy: a small fixed category list with a hard per-listing cap of one or two, enforced by a staleness downgrade to uncategorized rather than delisting; capped structured tags once search overtakes browsing; operator-curated, never purchasable layers on a compounding mandate. Uncapped self-selection is deleted.
- Localization: opt-in per market by default; auto-translate with vendor override with machine-translation infrastructure; region-locked only together with region gating; all-or-nothing capability declaration when buyers treat language support as purchase-blocking.
- Badges: no badge program is a legitimate architecture. Otherwise a dedicated trust surface apart from the marketing copy, with a written currency rule (a lapsed badge comes down after a stated grace period); renewal-linked display once badges carry lapsable thresholds. Never claim or imply an external listing-quality certification - no such body exists; position compositionally (WCAG for accessibility, recognised security attestations, your own rubric for content).
- Decay: the staleness downgrade is the always-on floor; the default adds "out-of-date listing content" as a named violation backed by an operator-side calendar sweep aligned to the release cycle; version-currency enforcement when the platform ships versioned releases. Fees are not a freshness mechanism. Every new requirement applied to listed apps gets a notice runway.
- Gates: every required field carries an objective reject criterion; exactly one geometry per visual asset; a hard per-listing category cap; every listing has a decay trigger with an owner and a consequence; every displayable badge has a display location and a currency rule.
House rules
- Use only the input. Never invent a benchmark figure, a vendor, a buyer complaint or a claim about the platform.
- The page's checks are settled: never call a failed check fine or a passed one broken.
- No URL at all.
- Write in the language of the input; keep the JSON keys, field keys, rung ids and trigger ids in English.
- 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
rulebook
Write the published rulebook - the target, not a transcript of the draft. Turning a principle into a number or a named trigger, choosing a geometry or a cap, adding a core field the draft lacks, picking a ratio for an asset that has none and setting the runway length are design decisions; say what changed.
summary- one sentence, at most 40 words: what would let two reviewers disagree today, and the first fix.rungs- the six menus in the facts' order:menu(strictness,media,taxonomy,localization,badgesordecay),rung(the facts' rung id),why(one sentence).fields- one object per field, at least name, hook, body, screenshots, categories, landing, support and privacy:field(the field key),requirement(one sentence),reject_criterion(one sentence holding a number or a trigger id a submitter can self-check).media-geometry(objects:asset,ratiosuch as 16:9,countsuch as 3-5 - one per asset type),video_seconds(a number),accessibility(short strings).triggers- objects:id(keyword-spam, unattributed-testimonial, guarantee, trademark-order, misrepresentation, landing-page, support-channel, privacy-policy or out-of-date - each once, all nine),rule(one sentence; keyword-spam names its count, trademark-order its accepted and rejected pair).taxonomy-cap(a number),mechanism(one sentence: how drift is enforced).decay- objects:trigger,owner,consequence(short strings).badges- objects:badge,location,currency(short strings); a planned program counts as a program; [] only when there is none.runway- one sentence: the notice period before a new requirement applies to listed apps.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "rulebook",
"summary": "one sentence",
"rungs": [ { "menu": "strictness", "rung": "triggers", "why": "one sentence" } ],
"fields": [ { "field": "name", "requirement": "one sentence", "reject_criterion": "one sentence" } ],
"media": { "geometry": [ { "asset": "screenshot", "ratio": "16:9", "count": "3-5" } ], "video_seconds": 30, "accessibility": [ "alt text on informative images" ] },
"triggers": [ { "id": "keyword-spam", "rule": "one sentence" } ],
"taxonomy": { "cap": 2, "mechanism": "one sentence" },
"decay": [ { "trigger": "short", "owner": "short", "consequence": "short" } ],
"badges": [ ],
"runway": "one sentence",
"caveats": [ ]
}
The page holds it to its own facts (check() in lrreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL; no backticks; no percentage; a summary of at most 40 words; the six rungs as ranked, each with a reason; each core field once with a requirement and a self-checkable reject criterion; one ratio per asset, the screenshot geometry published, a video ceiling within the evaluator's, an accessibility floor; all nine triggers once, keyword spam with its count, the naming rule naming the platform; the draft's category cap (or one or two) and how drift is enforced; complete decay rows; badges [] without a program, complete rows with one; a runway; no external listing-quality certification.
review
Review the sample listing against the rulebook - what a reviewer would send the submitter.
summary- one sentence, at most 40 words.verdict-rejectoraccept.violations- objects:trigger(the page's trigger id, orjudgmentfor a finding the page cannot compute, such as a body that leads with mechanism instead of outcome, or misrepresentation),field(the listing field),finding(one sentence citing the listing - describe an address, never copy it),fix(one sentence: what the submitter changes).passes- short strings: what already meets the bar. A check the page marks fix (warn), not fail, is not a rejection on its own: put it in caveats, or make it a judgment violation only when the rulebook makes it rejectable.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "review",
"summary": "one sentence",
"verdict": "reject",
"violations": [ { "trigger": "trademark-order", "field": "name", "finding": "one sentence", "fix": "one sentence" } ],
"passes": [ "a short string" ],
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; the same text rules; a verdict of reject or accept, reject whenever the page found a violation and only with a judgment finding when it found none; every page violation once with its trigger id on its field; no trigger the page passed; a finding and a fix for every violation.
Worked examples
Three runs from the page's free samples (invented marketplaces): the rulebook for a marketplace opening to vendors at scale, the review of its sample listing carrying that rulebook, and the review for a small operator-run marketplace. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.
rulebook - A marketplace opening to vendors at scale
The page read it as: the rulebook has gaps; 0 of 5 gates; rungs: strictness exact, media fixed, taxonomy curated, localization optin, badges trust-surface, decay downgrade-policy; failing rulebook checks: objective, geometry, taxonomy, decay, badges.
Input (facts is 3337 characters; the first 1398 are shown):
{
"task": "rulebook",
"market": "Marketplace: Pemryx Marketplace; platform name: Pemryx\nWhat it sells and to whom: Third-party apps for Pemryx, a project-management platform sold to IT departments; admins vet every install.\nStage: Designing from scratch, pre-launch; listings: Thousands; Vendors maintain their own listings; curation: Hybrid, with a certified tier; Admins or committees do diligence\nVersioned releases or scheduled deprecations: no; languages: one; machine translation: no; region-gated visibility: no; badges: A badge program exists, lapsable thresholds no\nHard deadline: yes; A compounding asset; effort ceiling: Medium; partner console: no; ranked search: no; link crawler: no; search-led discovery: no",
"draft": "Draft rules (field | rule | limit):\n- name | max characters | 40\n- hook | should be compelling | - | principles only\n- description | must show clear value to customers | - | principles only\n- screenshots | high-quality images of the app | - | principles only\n- landing page | required | -\n- support | must link a support channel | -\nMedia (asset | ratio | count): screenshot 16:9 3-5; screenshot 8:5 3-5; icon 1:1 1-1; video ceiling: 120; category cap: none; keyword repetition count: 3\nDecay (trigger | owner | consequence): Screenshots older than the current UI | - | Downgraded to uncategorized\nBadges (badge | location | currency): Verified Partner | next to the app name | -\nSample listing: name \"Pemryx Timesheets Pro\"; hook \"Track time in Pemryx\"; body:\nPemryx Timesheets Pro is the timesheet app for Pemryx. Timesheets sync with Pemryx tasks, timesheets roll up by project, and timesheets export to payroll. Guaranteed to cut reporting time in half. \"We stopped chasing timesheets the week we installed it.\"\ncategories: Productivity, Finance, HR, Reporting; screenshots: 1920x1080, 1280x800, 1600x900; video: 95 s; landing: https://example.com/timesheets-brochure.pdf; support: none; privacy: https://example.com/privacy",
"carry": "",
"facts": "The page's checks (12 fail, 2 warn, 4 pass, 3 n/a); gates passed 0 of 5:\n- The rulebook | objective | fail | no rule for categories, privacy; principles only: hook (\"should be compelling\"); description (\"must show clear value to customers\"); screenshots (\"high-quality images of the app\")\n- The rulebook | geometry | fail | several geometries for screenshot\n- The rulebook | taxonomy | fail | uncapped self-selection - every listing will claim every category\n- The rulebook | decay | fail | Screenshots older than the current UI: no owner\n- The rulebook | badges | fail | Verified Partner: no currency rule\n- The rulebook | trademark | warn | no naming-order rule - three benchmarked operators enforce it\n- The rulebook | video | warn | 120 s; diligence buyers: 30-second function proof, marketing footage banned, captions required\n- The sample listing | name-length | pass | 21 characters against a limit of 40\n- The sample listing | hook-length | na | the draft sets no limit for the hook\n- The sample listing | body-length | na | the draft sets no limit for the body\n- The sample listing | trademark-order | fail | \"Pemryx Timesheets Pro\" leads with Pemryx - the accepted shape is \"<App> for Pemryx\"\n- The sample listing | keyword-spam | fail | timesheets x6, pemryx x5 - more than 3\n- The sample listing | unattributed-testimonial | fail | 1 quoted testimonial with no named source\n- The sample\n\n[... 1939 more characters cut from this display ...]"
}
Reply:
{
"task": "rulebook",
"summary": "Three principles-only rules, two screenshot ratios and no category cap let two reviewers reject different listings today; first replace the hook, body and screenshot principles with exact limits and named triggers.",
"rungs": [
{
"menu": "strictness",
"rung": "exact",
"why": "Vendors maintain thousands of their own listings, so consistency has to come from an exact limit on every field rather than from the reviewer."
},
{
"menu": "media",
"rung": "fixed",
"why": "One published ratio and count per asset type, plus content rules, gives every submitter the same target."
},
{
"menu": "taxonomy",
"rung": "curated",
"why": "A compounding mandate with a certified tier calls for operator-curated layers on top of a hard category cap, never purchasable."
},
{
"menu": "localization",
"rung": "optin",
"why": "The marketplace runs in one language with no machine translation, so markets are added per market when needed."
},
{
"menu": "badges",
"rung": "trust-surface",
"why": "Verified Partner moves to a trust section apart from the marketing copy and gains a written currency rule."
},
{
"menu": "decay",
"rung": "downgrade-policy",
"why": "With no versioned releases, the staleness downgrade is the floor and out-of-date content becomes a named violation found by an operator sweep."
}
],
"fields": [
{
"field": "name",
"requirement": "The app name follows the naming order App for Pemryx and stays within 40 characters.",
"reject_criterion": "Reject past 40 characters or on trademark-order."
},
{
"field": "hook",
"requirement": "Changed from a principle to a limit: one sentence stating the customer outcome, at most 80 characters.",
"reject_criterion": "Reject past 80 characters or on keyword-spam."
},
{
"field": "body",
"requirement": "Changed from a principle to a limit: at most 1500 characters, opening with the customer outcome and tying each feature claim to a measurable result.",
"reject_criterion": "Reject past 1500 characters or on keyword-spam, unattributed-testimonial, guarantee or misrepresentation."
},
{
"field": "screenshots",
"requirement": "Changed from a principle to a spec: 3 to 5 images at 16:9, at least one of the real app UI, with no PII and no pricing or review claims.",
"reject_criterion": "Reject fewer than 3 or more than 5, any image off 16:9, or no image of the real app UI."
},
{
"field": "categories",
"requirement": "New rule: choose at most 2 categories from the published list.",
"reject_criterion": "Reject more than 2 categories or any category off the published list."
},
{
"field": "landing",
"requirement": "A public web page about the app; a PDF or a code repository does not count.",
"reject_criterion": "Reject on landing-page."
},
{
"field": "support",
"requirement": "A support channel with a response commitment stated in business days.",
"reject_criterion": "Reject on support-channel when no channel or no response time is stated."
},
{
"field": "privacy",
"requirement": "New rule: a linked privacy policy covering data collected, data stored, retention and deletion requests.",
"reject_criterion": "Reject on privacy-policy when the link is missing or any of the 4 coverage items is absent."
}
],
"media": {
"geometry": [
{
"asset": "screenshot",
"ratio": "16:9",
"count": "3-5"
},
{
"asset": "icon",
"ratio": "1:1",
"count": "1"
}
],
"video_seconds": 30,
"accessibility": [
"alt text on every informative image",
"captions on every video",
"WCAG 2.2 AA contrast"
]
},
"triggers": [
{
"id": "keyword-spam",
"rule": "Any term other than common words repeated more than 3 times across the hook and body."
},
{
"id": "unattributed-testimonial",
"rule": "Any quote without a named person, role and organisation."
},
{
"id": "guarantee",
"rule": "Any guaranteed or promised result the listing cannot substantiate."
},
{
"id": "trademark-order",
"rule": "Pemryx comes last: Task Tracker for Pemryx is accepted, Pemryx Task Tracker is rejected."
},
{
"id": "misrepresentation",
"rule": "Any claim the app does not deliver, or a third-party dependency or its cost left undisclosed."
},
{
"id": "landing-page",
"rule": "The landing page link is missing, not public, or points to a PDF or a code repository."
},
{
"id": "support-channel",
"rule": "No support channel, or no stated response commitment."
},
{
"id": "privacy-policy",
"rule": "No privacy policy link, or a policy missing an item of the coverage checklist."
},
{
"id": "out-of-date",
"rule": "Screenshots or copy that no longer match the current app UI or behaviour."
}
],
"taxonomy": {
"cap": 2,
"mechanism": "Listings past the cap or with a lapsed category are downgraded to uncategorized rather than delisted, and the certified tier is curated by the operator and never sold."
},
"decay": [
{
"trigger": "Screenshots older than the current UI",
"owner": "Marketplace operations, quarterly sweep",
"consequence": "out-of-date violation, then downgraded to uncategorized"
}
],
"badges": [
{
"badge": "Verified Partner",
"location": "trust section of the listing, apart from the marketing copy",
"currency": "removed 30 days after eligibility lapses"
}
],
"runway": "Every new requirement applies to listed apps 60 days after notice to their vendors.",
"caveats": [
"Hook and body limits, the second screenshot ratio's removal and the 30-second video ceiling are design decisions new to this rulebook.",
"Content quality is the marketplace's own rubric; no outside body certifies listing quality."
]
}
What step 5 prints for it: name | hook | body | screenshots | categories | landing | support | privacy
The page's check: kept - nothing to flag
review - A marketplace opening to vendors at scale
The input carries the saved rulebook's decisions (R.laneForm fills carry from the rulebook reply), as the page's handoff does. The page's listing violations: trademark-order, keyword-spam, unattributed-testimonial, guarantee, landing-page, support-channel, screenshot-geometry.
Input (facts is 2941 characters; the first 1398 are shown):
{
"task": "review",
"market": "Marketplace: Pemryx Marketplace; platform name: Pemryx\nWhat it sells and to whom: Third-party apps for Pemryx, a project-management platform sold to IT departments; admins vet every install.\nStage: Designing from scratch, pre-launch; listings: Thousands; Vendors maintain their own listings; curation: Hybrid, with a certified tier; Admins or committees do diligence\nVersioned releases or scheduled deprecations: no; languages: one; machine translation: no; region-gated visibility: no; badges: A badge program exists, lapsable thresholds no\nHard deadline: yes; A compounding asset; effort ceiling: Medium; partner console: no; ranked search: no; link crawler: no; search-led discovery: no",
"draft": "Draft rules (field | rule | limit):\n- name | max characters | 40\n- hook | should be compelling | - | principles only\n- description | must show clear value to customers | - | principles only\n- screenshots | high-quality images of the app | - | principles only\n- landing page | required | -\n- support | must link a support channel | -\nMedia (asset | ratio | count): screenshot 16:9 3-5; screenshot 8:5 3-5; icon 1:1 1-1; video ceiling: 120; category cap: none; keyword repetition count: 3\nDecay (trigger | owner | consequence): Screenshots older than the current UI | - | Downgraded to uncategorized\nBadges (badge | location | currency): Verified Partner | next to the app name | -\nSample listing: name \"Pemryx Timesheets Pro\"; hook \"Track time in Pemryx\"; body:\nPemryx Timesheets Pro is the timesheet app for Pemryx. Timesheets sync with Pemryx tasks, timesheets roll up by project, and timesheets export to payroll. Guaranteed to cut reporting time in half. \"We stopped chasing timesheets the week we installed it.\"\ncategories: Productivity, Finance, HR, Reporting; screenshots: 1920x1080, 1280x800, 1600x900; video: 95 s; landing: https://example.com/timesheets-brochure.pdf; support: none; privacy: https://example.com/privacy",
"carry": "Rungs: strictness exact, media fixed, taxonomy curated, localization optin, badges trust-surface, decay downgrade-policy\nReject criteria: name - Reject past 40 characters or on trademark-order.; hook - Reject past 80 characters or on keyword-spam.; body - Reject past 1500 characters or on keyword-spam, unattributed-testimonial, guarantee or misrepresentation.; screenshots - Reject fewer than 3 or more than 5, any image off 16:9, or no image of the real app UI.; categories - Reject more than 2 categories or any category off the published list.; landing - Reject on landing-page.; support - Reject on support-channel when no channel or no response time is stated.; privacy - Reject on privacy-policy when the link is missing or any of the 4 coverage items is absent.\nMedia: screenshot 16:9 x3-5, icon 1:1 x1; video 30 s; category cap 2\nTriggers: keyword-spam - Any term other than common words repeated more than 3 times across the hook and body.; unattributed-testimonial - Any quote without a named person, role and organisation.; guarantee - Any guaranteed or promised result the listing cannot substantiate.; trademark-order - Pemryx comes last: Task Tracker for Pemryx is accepted, Pemryx Task Tracker is rejected.; misrepresentation - Any claim the app does not deliver, or a third-party dependency or its cost left undisclosed.; landing-page - The landing page link is missing, not public, or points to a PDF or a code repository.; support-channel - No support channel, or no stated response commitment.; privacy-policy - No privacy policy link, or a policy missing an item of the coverage checklist.; out-of-date - Screenshots or copy that no longer match the current app UI or behaviour.",
"facts": "The page's checks (12 fail, 2 warn, 4 pass, 3 n/a); gates passed 0 of 5:\n- The rulebook | objective | fail | no rule for categories, privacy; principles only: hook (\"should be compelling\"); description (\"must show clear value to customers\"); screenshots (\"high-quality images of the app\")\n- The rulebook | geometry | fail | several geometries for screenshot\n- The rulebook | taxonomy | fail | uncapped self-selection - every listing will claim every category\n- The rulebook | decay | fail | Screenshots older than the current UI: no owner\n- The rulebook | badges | fail | Verified Partner: no currency rule\n- The rulebook | trademark | warn | no naming-order rule - three benchmarked operators enforce it\n- The rulebook | video | warn | 120 s; diligence buyers: 30-second function proof, marketing footage banned, captions required\n- The sample listing | name-length | pass | 21 characters against a limit of 40\n- The sample listing | hook-length | na | the draft sets no limit for the hook\n- The sample listing | body-length | na | the draft sets no limit for the body\n- The sample listing | trademark-order | fail | \"Pemryx Timesheets Pro\" leads with Pemryx - the accepted shape is \"<App> for Pemryx\"\n- The sample listing | keyword-spam | fail | timesheets x6, pemryx x5 - more than 3\n- The sample listing | unattributed-testimonial | fail | 1 quoted testimonial with no named source\n- The sample\n\n[... 1543 more characters cut from this display ...]"
}
Reply:
{
"task": "review",
"summary": "Rejected on seven named triggers plus three judgment findings; rename to Timesheets for Pemryx, cut the repetition, guarantee and anonymous quote, and supply a public landing page and a support channel.",
"verdict": "reject",
"violations": [
{
"trigger": "trademark-order",
"field": "name",
"finding": "Pemryx Timesheets Pro leads with the platform name.",
"fix": "Rename to the App for Pemryx shape, for example Timesheets Pro for Pemryx."
},
{
"trigger": "keyword-spam",
"field": "body",
"finding": "Timesheets appears 6 times and Pemryx 5 times, past the limit of 3.",
"fix": "Cut each term to 3 uses or fewer across the hook and body."
},
{
"trigger": "unattributed-testimonial",
"field": "body",
"finding": "The quote about no longer chasing timesheets names no source.",
"fix": "Attribute it to a named person, role and organisation, or remove it."
},
{
"trigger": "guarantee",
"field": "body",
"finding": "Guaranteed to cut reporting time in half is an unsubstantiated guarantee.",
"fix": "Remove the guarantee or replace it with a sourced, measured result."
},
{
"trigger": "landing-page",
"field": "landing",
"finding": "The landing page link points to a PDF brochure.",
"fix": "Link a public web page about the app."
},
{
"trigger": "support-channel",
"field": "support",
"finding": "No support channel is given.",
"fix": "Add a support channel with a response time in business days."
},
{
"trigger": "screenshot-geometry",
"field": "screenshots",
"finding": "The 1280x800 image is off the published 16:9.",
"fix": "Replace it with a 16:9 capture such as 1920x1080."
},
{
"trigger": "judgment",
"field": "body",
"finding": "The body opens by naming the app and then lists sync, roll-up and export mechanics instead of the customer outcome.",
"fix": "Open with the result for the admin's team and tie each feature to a measurable result."
},
{
"trigger": "judgment",
"field": "categories",
"finding": "Four categories (Productivity, Finance, HR, Reporting) exceed the new cap of 2.",
"fix": "Keep the 2 categories that best fit the app."
},
{
"trigger": "judgment",
"field": "video",
"finding": "The 95-second video exceeds the new 30-second ceiling for diligence buyers.",
"fix": "Cut it to a captioned function proof of 30 seconds or less with no marketing footage."
}
],
"passes": [
"Name is 21 characters against 40",
"Screenshot count of 3 is within 3-5",
"A privacy policy is linked"
],
"caveats": [
"The page confirmed the privacy link exists, not that it covers the 4 checklist items."
]
}
What step 5 prints for it: reject | name trademark-order | body keyword-spam | body unattributed-testimonial | body guarantee | landing landing-page | support support-channel | screenshots screenshot-geometry | body judgment | categories judgment | video judgment
The page's check: kept - nothing to flag
review - A small operator-run marketplace
The input carries the saved rulebook's decisions (R.laneForm fills carry from the rulebook reply), as the page's handoff does. The page's listing violations: unattributed-testimonial.
Input (draft is 1582 characters; the first 1399 are shown; facts is 2313 characters; the first 1392 are shown):
{
"task": "review",
"market": "Marketplace: Lanternly Apps; platform name: Lanternly\nWhat it sells and to whom: A curated set of add-ons for Lanternly, a scheduling suite for clinics; clinic staff install add-ons themselves.\nStage: Live, content rules ad hoc; listings: Under 100 listings in 12 months; Our team maintains listings for vendors; curation: Hard gate; End users install self-serve\nVersioned releases or scheduled deprecations: no; languages: several; machine translation: no; region-gated visibility: no; badges: No badge program\nHard deadline: no; A compounding asset; effort ceiling: Low; partner console: no; ranked search: no; link crawler: yes; search-led discovery: no",
"draft": "Draft rules (field | rule | limit):\n- name | at most 30 characters; trademark order: <App> for Lanternly | 30\n- hook | at most 80 characters | 80\n- body | at most 1500 characters; reject if keyword-spam, guarantee or unattributed-testimonial | 1500\n- screenshots | 3 to 5 images of the real app UI, no PII | 5\n- categories | at most 2 from the published list | 2\n- landing page | must be a public web page, not a PDF or repository | -\n- support | must state a response time in business days | -\n- privacy | must link a policy covering data stored and retention | -\nMedia (asset | ratio | count): screenshot 16:10 3-5; video ceiling: 150; category cap: 2; keyword repetition count: 4\nDecay (trigger | owner | consequence): Listing not updated in 12 months | Marketplace editor | Moved to uncategorized until updated; App UI changed in a release | Vendor | Out-of-date content violation, 30-day fix window\nBadges (badge | location | currency): none\nSample listing: name \"Reminders for Lanternly\"; hook \"Fewer no-shows with text reminders patients actually answer.\"; body:\nClinics using Reminders cut missed appointments by sending a confirmation text two days ahead and a reminder on the morning of the visit. Patients reply to confirm or reschedule, and the change lands in the Lanternly calendar. \"Our front desk gets an hour back every day.\"\ncategories: Patient engagement, Scheduling; screenshots:\n\n[... 183 more characters cut from this display ...]",
"carry": "Rungs: strictness triggers, media content-first, taxonomy capped, localization optin, badges none, decay downgrade-policy\nReject criteria: name - Reject past 30 characters or on trademark-order.; hook - Reject past 80 characters or on keyword-spam.; body - Reject past 1500 characters or on keyword-spam, guarantee, unattributed-testimonial or misrepresentation.; screenshots - Reject fewer than 3 or more than 5, any image off 16:10, or any PII shown.; categories - Reject more than 2 categories or any category off the published list.; landing - Reject on landing-page.; support - Reject on support-channel when no response time in business days is stated.; privacy - Reject on privacy-policy when either coverage item is missing.\nMedia: screenshot 16:10 x3-5; video 150 s; category cap 2\nTriggers: keyword-spam - Any term other than common words repeated more than 4 times across the hook and body.; unattributed-testimonial - Any quote without a named person, role and clinic or organisation.; guarantee - Any guaranteed result the listing cannot substantiate.; trademark-order - Lanternly comes last: Task Tracker for Lanternly is accepted, Lanternly Task Tracker is rejected.; misrepresentation - New trigger: any claim the add-on does not deliver, or an undisclosed third-party dependency or its cost.; landing-page - The landing page link is missing, not public, or a PDF or repository.; support-channel - No support channel, or no response time in business days.; privacy-policy - No policy link, or a policy that omits data stored or retention.; out-of-date - Screenshots or copy that no longer match the add-on after a release changed its UI.",
"facts": "The page's checks (1 fail, 0 warn, 18 pass, 1 n/a); gates passed 4 of 4:\n- The rulebook | objective | pass | 8 fields, every one objective\n- The rulebook | geometry | pass | screenshot 16:10\n- The rulebook | taxonomy | pass | at most 2 categories per listing\n- The rulebook | decay | pass | 2 triggers\n- The rulebook | badges | na | no badge program - a legitimate architecture\n- The rulebook | trademark | pass | present\n- The rulebook | video | pass | 150 s; self-serve installs: up to 2-3 minutes, promotional tone allowed, captions required\n- The sample listing | name-length | pass | 23 characters against a limit of 30\n- The sample listing | hook-length | pass | 60 characters against a limit of 80\n- The sample listing | body-length | pass | 272 characters against a limit of 1500\n- The sample listing | trademark-order | pass | \"<App> for Lanternly\"\n- The sample listing | keyword-spam | pass | no term past 4\n- The sample listing | unattributed-testimonial | fail | 1 quoted testimonial with no named source\n- The sample listing | guarantee | pass | no guarantee claims\n- The sample listing | landing-page | pass | a public page\n- The sample listing | support-channel | pass | with a response commitment\n- The sample listing | privacy-policy | pass | linked\n- The sample listing | screenshot-geometry | pass | 3 screenshots at 16:10\n- The sample listing | screenshot-count | pass | 3\n\n[... 921 more characters cut from this display ...]"
}
Reply:
{
"task": "review",
"summary": "Rejected only for an anonymous testimonial; everything else meets the rulebook, so attributing or removing the front-desk quote clears the listing.",
"verdict": "reject",
"violations": [
{
"trigger": "unattributed-testimonial",
"field": "body",
"finding": "The quote about the front desk getting an hour back names no source.",
"fix": "Attribute it to a named person, role and clinic, or remove it."
}
],
"passes": [
"Name follows the App for Lanternly order at 23 characters",
"Hook and body lead with the clinic's outcome, fewer no-shows",
"3 screenshots at 16:10",
"2 categories against a cap of 2",
"Public landing page, a support reply within 1 business day and a linked privacy policy"
],
"caveats": [
"The page confirmed the privacy link exists, not that it covers data stored and retention."
]
}
What step 5 prints for it: reject | body unattributed-testimonial
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./lrkit.js"); require("./lrreply.js")), then:
const K = RuleKit, R = RuleReply;
const f = K.form(Object.assign(K.blank(), { name: "Acme Apps", platform: "Acme",
fields: "name | at most 30 characters | 30\nhook | should be compelling |", catcap: "2", kwmax: "3",
lname: "Acme Sync", lbody: "Guaranteed results." }));
// f.rules (each read as objective or not), f.gate (the five gates), f.ladder (the rungs), f.lints and f.violations (the listing)
// K.selfCheckMd(f) - the submitter self-check
const b = K.buildInput(f, "rulebook"); // or "review" (R.laneForm(form, "review", rulebook) fills carry)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "rulebook");
// R.check(reply, b) // {status: kept or flagged, notes}
Get a token for the steps below from /tokens.html.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://listing-rulebook.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://listing-rulebook.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"]
// listing-rulebook.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://listing-rulebook.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://listing-rulebook.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
}
// Listing Rulebook.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 Listing Rulebook {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://listing-rulebook.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://listing-rulebook.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://listing-rulebook.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://listing-rulebook.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":"listing-rulebook"}' | 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": "listing-rulebook"})["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: "listing-rulebook" })).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": "listing-rulebook"}, 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", "listing-rulebook"), 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: "listing-rulebook" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "listing-rulebook"])["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 = "listing-rulebook" }))!["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": "rulebook", "market": "Marketplace: Pemryx Marketplace; platform name: Pemryx\nWhat it sells and to whom: ...", "draft": "Draft rules (field | rule | limit):\n- name | max characters | 40\n- hook | should be compelling | - | principles only ...", "carry": "", "facts": "The page's checks (13 fail, ...); gates passed 0 of 5:\n- The rulebook | objective | 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": "rulebook", "market": "Marketplace: Pemryx Marketplace; platform name: Pemryx\nWhat it sells and to whom: ...", "draft": "Draft rules (field | rule | limit):\n- name | max characters | 40\n- hook | should be compelling | - | principles only ...", "carry": "", "facts": "The page's checks (13 fail, ...); gates passed 0 of 5:\n- The rulebook | objective | 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: "rulebook" or "review", market, draft, 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 listing-rulebook:<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 rulebook reply carries summary, rungs, fields, media, triggers, taxonomy, decay, badges, runway and caveats; a review reply carries summary, verdict, violations, passes and caveats. The code below prints the gist of either: each field and its reject criterion, or the verdict and each violation.
KEY="listing-rulebook:$(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 == "review" then (.verdict, (.violations[] | "\(.field) \(.trigger)"))
else (.fields[] | "\(.field): \(.reject_criterion)") end' reply.json
import hashlib, time
key = "listing-rulebook:" + 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"] == "review":
print(reply["verdict"])
for x in reply["violations"]:
print(x["field"], x["trigger"])
else:
for x in reply["fields"]:
print(x["field"], x["reject_criterion"])
import { createHash } from "node:crypto";
const key = `listing-rulebook:${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 === "review") { console.log(reply.verdict); for (const x of reply.violations) console.log(x.field, x.trigger); }
else { for (const x of reply.fields) console.log(x.field, x.reject_criterion); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "listing-rulebook:" + 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"], reply["verdict"])
key := "fields"
if reply["task"] == "review" {
key = "violations"
}
ls, _ := reply[key].([]any)
for _, x := range ls {
xx := x.(map[string]any)
fmt.Println(xx["field"], xx["reject_criterion"], xx["trigger"])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "listing-rulebook:" + 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("review")) {
System.out.println(reply.path("verdict").asText());
for (JsonNode x : reply.path("violations")) System.out.println(x.path("field").asText() + " " + x.path("trigger").asText());
} else {
for (JsonNode x : reply.path("fields")) System.out.println(x.path("field").asText() + " " + x.path("reject_criterion").asText());
}
require "digest"
key = "listing-rulebook:#{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"] == "review"
puts reply["verdict"]
reply["violations"].each { |x| puts "#{x["field"]} #{x["trigger"]}" }
else
reply["fields"].each { |x| puts "#{x["field"]} #{x["reject_criterion"]}" }
end
$key = "listing-rulebook:" . $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"] === "review") { echo $reply["verdict"], "\n"; foreach ($reply["violations"] as $x) echo $x["field"], " ", $x["trigger"], "\n"; }
else { foreach ($reply["fields"] as $x) echo $x["field"], " ", $x["reject_criterion"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"listing-rulebook:{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 == "review") { Console.WriteLine(reply["verdict"]); foreach (var x in reply["violations"]!.AsArray()) Console.WriteLine($"{x!["field"]} {x["trigger"]}"); }
else { foreach (var x in reply["fields"]!.AsArray()) Console.WriteLine($"{x!["field"]} {x["reject_criterion"]}"); }
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: listing-rulebook:$(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": "rulebook", "market": "Marketplace: Pemryx Marketplace; platform name: Pemryx\nWhat it sells and to whom: ...", "draft": "Draft rules (field | rule | limit):\n- name | max characters | 40\n- hook | should be compelling | - | principles only ...", "carry": "", "facts": "The page's checks (13 fail, ...); gates passed 0 of 5:\n- The rulebook | objective | 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": "listing-rulebook:" + 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": `listing-rulebook:${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", "listing-rulebook:"+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", "listing-rulebook:" + 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" => "listing-rulebook:#{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: listing-rulebook:" . 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", $"listing-rulebook:{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);