Run Listing Lift from your own code
Both metered lanes are available over HTTP and each returns one JSON object. Listing Lift is for a B2B SaaS vendor's own listing on Salesforce AppExchange, the HubSpot App Marketplace, the Atlassian Marketplace, the Shopify App Store or the Slack Marketplace - not for consumer app stores. The division of labour: the page classifies the marketplace data-rich or data-dark, matches every buyer term as whole words against every field the marketplace indexes, checks the title convention, the field limits, the gallery, the video, the reviews and the review asks, ranks the instrumentation, optimization and review-velocity ladders, reads the five plan gates, labels every ranking claim with its provenance and confirms that no verdict contradicts a check, then hands it all to the run as facts. The run writes the rewritten listing or the review and refresh program; the page then holds it to its own facts (llreply.js). Listing Lift is not affiliated with or endorsed by Salesforce, HubSpot, Atlassian, Shopify or Slack.
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-lift"}), 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, marketplace, listing, terms, facts) is missing. The page always sends all nine of task, marketplace, listing, terms, gallery, reviews, context, carry, facts; carry is an empty string for rewrite. |
| 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 listing, then the standing work:
rewrite- the listing rewrite: one row per indexed field (the page's field id, the text within the page's limit and in the marketplace's title convention, and the buyer terms it covers word for word), the first screen by the input's asset numbers, the video decision (keep, add or skip) against the platform's ceiling, one fix per failing listing or gallery check by check id and subject, and every ranking claim by the page's claim id with exactly the page's label - every platform figure with its dagger.program- the review-velocity and refresh program: the page's classification (data_rich or data_dark), the five review triggers in the page's ladder order with the page's status (use, add or skip), a stop for each deleted tactic in use, a neutral ask one sentence per line (or none on a data-dark marketplace), a 30-60 day cadence with the owner and the next date, the page's six KPIs, one fix per failing instrumentation, reviews or refresh check, and every claim with its label.
A missing or unknown task is answered as rewrite. Both lanes send the same nine 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 rewrite's fields, first screen and claims into carry for the program lane.
The input
| Field | Required | What |
|---|---|---|
task | yes | rewrite or program. If it is missing or neither, do rewrite and set "task": "rewrite". |
marketplace | yes | the marketplace's name and id, live or pre-submission, its classification, its fields (id, whether it is indexed, the claim behind that and its label, the limit), its title convention and its video ceiling. |
listing | yes | the listing as pasted, one line per field of this marketplace (id, label, text). |
terms | yes | the buyer search terms, one per line with their source (support tickets, sales calls or the search-term report). |
gallery | send it | the gallery assets, one per line: number, kind, caption, notes; then the demo video's length. |
reviews | send it | the review summary: count, average, newest, trailing 90 days, the newest ten dates; or that the marketplace has no review system. |
context | send it | the interview answers: brand and maturity, analytics on, the funnel numbers, the badge and whether procurement gates on it, the review asks in use, the uninstall trend, the owner, the last change, the deadline, the mandate, the effort ceiling and the unfair advantages. |
carry | send it (empty for rewrite) | program task only: the rewrite's fields, first screen and claims when the rewrite ran first; may be empty. |
facts | yes | the page's checks, one line each with the area, the id, the subject, the status and the evidence separated by vertical bars, with the claim id and its label in brackets; then the coverage matrix, the three ladders, the badge, the plan gates, the provenance table, the KPIs, the refresh window and the footnote. A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it and reply with only the JSON object. |
The rules the model applies
From the source skill, @samber/integration-listing-optimization - the only standard the run applies:
- Scope: the vendor's own listing on someone else's B2B marketplace - found in marketplace search, views converted to installs, review velocity built without breaking platform policy, the position defended. Consumer app stores enter only as structural analogy: their audit rubrics and gallery order transfer, their numbers never do.
- Data-rich versus data-dark: Shopify, Atlassian and AppExchange expose a real view-to-install funnel; HubSpot shows install counts with no funnel; Slack exposes nothing - no listing analytics and no review system. On a data-dark marketplace write the best listing once, drive traffic from outside, self-track installs, and skip the review program entirely.
- Confirmed versus inferred: only Atlassian (more reviews and a higher rating improve search rank; rich media drives engagement; OpenSearch scored by NDCG) and Shopify (Built for Shopify apps appear higher in search) say anything vendor-official about ranking. Everything else is practitioner inference, and AppExchange and HubSpot ranking advice is inferred end to end. Every ranking claim carries one label, and a lower tier is never presented as a higher one: vendor-official > corroborated inference > single-source inference > analogy. Promise direction, never magnitude.
- Instrument before optimizing: native dashboards on day one on every marketplace that has them; platform-supported external wiring once weekly decisions depend on funnel data; a third-party tracker once you contest named keywords; the self-built install ledger (your auth-token store plus the uninstall event) is starved, and mandatory on a data-dark marketplace.
- The optimization ladder by efficiency: keyword rewrite > first-screen media overhaul > review-velocity program > demo video > paid marketplace ads > badge pursuit. A hard deadline promotes text-only levers over standing programs; a compounding mandate promotes the standing programs; a low effort ceiling deletes options rather than demoting them. Say which answer moved which rung.
- The keyword rewrite: put the buyers' real words into the fields the marketplace indexes, in the title convention that marketplace documents. Shopify instructs brand-first names ("QTeck Announcement Bar", not "Announcement Bar QTeck") - the opposite of mobile keyword-first advice, so a convention never transfers between marketplaces. Shopify's search terms take up to five terms†, one concept per term; Atlassian's summary is capped at 250 characters†. AppExchange and HubSpot publish their limits only in their own listing builders.
- The first screen: unique, in-UI, captioned screenshots ordered hero, then differentiator, then most-loved feature (an analogy-labelled ordering), cropped of sensitive data, with no pricing or review claims baked into the image. Shopify hard-rejects duplicate and logo-only images.
- Demo video is platform-gated: add one only where the marketplace surfaces video prominently, at its ceiling - 30 seconds† of function proof on Atlassian, 30-90 seconds† on Slack, 2-3 minutes† on Shopify (screencast capped at 25% of runtime†), 1-3 minutes† on AppExchange.
- Review velocity: recency and velocity outweigh lifetime totals (corroborated inference: 50 reviews in 30 days† outrank 500 static reviews†). The default rung is every trigger the platform itself operates (auto-invite, the copyable review link, the review API) plus an ask after a positive support interaction, at a natural pause, never blocking the workflow, with an opt-out. Then a lifecycle email to all active users (5-10%† conversion, corroborated), unconditional and sent to everyone who qualifies; then the milestone-timed personal ask; then one public reply per negative review. Atlassian auto-invites customers 30 days after purchase† and shows a star rating only after 10 reviews†.
- Deleted, not demoted: incentivized reviews in any form (discounts, gift cards, feature unlocks, withheld functionality), reviews from your own team, review swaps with other vendors, and asking only happy users. All five platforms ban incentives by agreement; one runs a graduated penalty ladder that ends at Partner-account termination.
- Badges: pursue one only when the marketplace officially confirms a ranking effect (only Built for Shopify does) or when enterprise procurement gates on it; rank badges by confirmed effect, never by prestige. AppExchange's Security Review is a listing gate, not a badge.
- Defend the position: refresh on a 30-60 day cycle with a named owner, watch uninstalls (a better listing cannot outrun churn - fix the product gap first), and re-verify the platform's current rules each cycle.
- The plan gates, self-set: every native analytics surface on (or data-dark with a ledger); every indexed field carrying buyer language in the documented convention; the first three gallery assets unique, in-UI and captioned with every ask on a sanctioned trigger; every ranking claim labelled; a 30-60 day refresh cycle with a named owner.
- Every platform figure carries † right after it. Close with the footnote: "† verify against the marketplace's current listing rules before relying on it - all five platforms revise them on an ongoing basis."
House rules
- Use only the input. Never invent a buyer term, a feature, a price, a review count, a rating, a badge, an install number, a date or a platform rule; the only figures you may quote are the skill's, each with † as the facts mark it, and the input's own values. When the input does not say, write "not stated in the input".
- The page's verdicts are settled: every check status, the classification, every ladder rung and status, the badge verdict, every gate and every claim's label. Never move them; explain them.
- Every ranking claim you make carries its provenance label: list it in
claimsby the page's claim id with exactly the page's label -vendor_official,corroborated,single_sourceoranalogy. Never state a claim the provenance table does not hold, and never raise a label. - A fix names the page's check id and its subject exactly as the facts write them - two assets can fail the same check.
- Never recommend incentivized reviews, reviews from your own team, review swaps or asking only happy users.
- Never quote a consumer app-store number as fact: structure transfers from consumer app stores, numbers do not.
- Every platform figure carries † right after it; the
footnotefield is exactly "† verify against the marketplace's current listing rules before relying on it - all five platforms revise them on an ongoing basis." - Never imply that Salesforce, HubSpot, Atlassian, Shopify or Slack endorses, approves, recommends or certifies the app.
- No URL: never write a scheme such as https and never copy a link. Write "the listing", "the vendor console" or "the partner dashboard" where a link belongs.
- Write in the language of the input; keep the JSON keys, check ids, field ids, claim ids and enum values 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
rewrite
Rewrite the listing: the indexed fields in the marketplace's convention with the buyers' own words, the first screen, the video decision, one fix per failing listing or gallery check, and the provenance of every ranking claim.
summary- one sentence, at most 40 words: what the rewrite changes and which buyer terms it now covers.fields- one object per indexed field the facts list, and no other field:field(exactly the page's field id:name,subtitle,search_terms,summary,tagline,short_description,keywordsordescription),text(the rewritten value: within the page's limit, in the marketplace's title convention, using only buyer terms from the input - never an invented claim, price, review count or badge; for a list field such assearch_termsorkeywords, each term on its own line separated by \n, for example "invoice sync\nquickbooks sync\nsales tax report"),terms(the buyer terms from the input that now appear in that text word for word).gallery- the first screen, at least the first three assets:n(the asset number from the input),caption(one short caption for it),position(1, 2, 3 and on, each once). Never put an asset the facts drop - pricing or a review claim in the image, a duplicate, or logo-only where the platform hard-rejects it - in positions 1 to 3.video- an object:decision(keep,addorskip),why(one sentence naming the platform's ceiling with its †, or why video is not worth it here).claims- one object per ranking claim the reply makes:claim(the page's claim id from the provenance table),label(exactly the page's label).fixes- one object per check the facts mark fail in the keywords, title and gallery areas:check(the page's check id),subject(the facts' subject for that row, or "" when the row has none),change(one concrete sentence).footnote- exactly the footnote.caveats- at most 4 short strings: what the vendor must confirm;[]when there is nothing to say.
{
"task": "rewrite",
"summary": "one sentence",
"fields": [ { "field": "name", "text": "Tallywren Invoice Sync", "terms": ["invoice sync"] } ],
"gallery": [ { "n": 2, "caption": "Every invoice synced to your ledger", "position": 1 } ],
"video": { "decision": "keep", "why": "one sentence" },
"claims": [ { "claim": "shp_brand_first", "label": "vendor_official" } ],
"fixes": [ { "check": "g_claims", "subject": "#1", "change": "one sentence" } ],
"footnote": "† verify against the marketplace's current listing rules before relying on it - all five platforms revise them on an ongoing basis.",
"caveats": [ ]
}
The page holds it to its own facts (check() in llreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL with a scheme; no backticks; a summary of at most 40 words; every platform figure with its dagger, and the exact footnote; no consumer app-store number; no implied endorsement by the marketplace; no deleted review tactic recommended; one row per indexed field and no other, each within the page's limit, the name in Shopify's brand-first convention, no number, price or badge the input never gave, and every covered term the input's and present word for word (matched by field plus term, so "sync" is never found in "async"); only the input's asset numbers, positions 1 and on each once, and no dropped asset in the first three (matched by asset number, never by a caption two assets share); a keep, add or skip video call that keeps no video over the ceiling; a fix for every listing or gallery fail matched by check id and subject; every ranking claim by the page's claim id with exactly its label - a label above the page's is flagged as an upgrade and put back, and a ranking sentence whose claim is not listed is flagged.
program
Plan reviews and refresh: the classification, the review triggers in the page's ladder order, the deleted tactics to stop, the sanctioned ask, the refresh cadence, the KPIs, one fix per failing instrumentation, reviews or refresh check, and the provenance of every ranking claim.
summary- one sentence, at most 40 words: the review and refresh program, or why there is no review program on a data-dark marketplace.classification- exactly the facts' classification:data_richordata_dark.triggers- the facts' five triggers in the facts' order:trigger(platform_triggers,post_support,lifecycle_email,milestone_askornegative_replies),status(exactly the facts' status:use,addorskip),how(one sentence). On a data-dark marketplace every status isskip.deleted- one object per deleted tactic the facts show in use:tactic(incentives,team_reviews,review_swapsorselective_asks),stop(one sentence on how to stop it).[]when none is in use.ask- the sanctioned review ask: neutral, unconditional, no incentive, never asking for a star count or only from happy users, each sentence on its own line. Separate the lines with a newline, written \n inside the JSON string - for example "Thanks for using Quillstead.\nIf you have a minute, an honest review on the listing helps other teams decide.\nYou can turn these requests off in settings." Exactly "" on a data-dark marketplace.cadence- an object:cycle_days(a number from 30 to 60),owner(exactly the owner the input names, else a role:marketplace owner,product marketing,product,support,customer success,engineeringordesign),next(a YYYY-MM-DD date inside the facts' refresh window, or exactly "set it" when the facts say so).kpis- the facts' six KPIs in the facts' order:kpi(views_conversion,keyword_coverage,review_velocity,average_rating,uninstall_rateorbadge_progress),now(exactly the facts' value, "unknown" included),where(one short phrase: where to read it).fixes- one object per check the facts mark fail in the instrumentation, reviews and refresh areas:check,subject("" when the row has none),change(one concrete sentence).claims- one object per ranking claim the reply makes:claim(the page's claim id),label(exactly the page's label).footnote- exactly the footnote.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "program",
"summary": "one sentence",
"classification": "data_rich",
"triggers": [ { "trigger": "platform_triggers", "status": "use", "how": "one sentence" } ],
"deleted": [ { "tactic": "incentives", "stop": "one sentence" } ],
"ask": "first sentence\nsecond sentence",
"cadence": { "cycle_days": 45, "owner": "Marketplace lead", "next": "2026-11-20" },
"kpis": [ { "kpi": "views_conversion", "now": "4.5% (430 installs from 9,600 views)", "where": "the vendor console" } ],
"fixes": [ { "check": "i_native", "subject": "", "change": "one sentence" } ],
"claims": [ { "claim": "gen_velocity", "label": "corroborated" } ],
"footnote": "† verify against the marketplace's current listing rules before relying on it - all five platforms revise them on an ongoing basis.",
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; the same text rules; the page's classification (put back if moved); the five triggers in the page's ladder order with the page's status (put back), and none but skip on a data-dark marketplace; a stop for every deleted tactic in use and no invented one; a neutral, unconditional ask with each sentence on its own line, or none on a data-dark marketplace; a cadence of 30 to 60 days with the input's owner (or a role) and a next date inside the page's window, or "set it"; the page's six KPIs in order with the page's values (put back); a fix for every instrumentation, reviews or refresh fail; every ranking claim with exactly its page label.
Worked examples
Three runs from the page's free samples (invented products): the rewrite for a Shopify listing that fails a lot, its review and refresh program carrying that rewrite, and the program for a data-dark Slack listing, where the review program is skipped. 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.
rewrite - Tallywren on Shopify - fails a lot
The page read it as: not yet - fails to fix first on Shopify App Store (data_rich); keyword coverage 33%; fail checks: i_native, k_field (subtitle), k_coverage, k_limit (search_terms), t_convention, g_first3_unique, g_claims (#1), g_duplicate (#3), g_logo_only (#4), g_video, a_deleted (incentives), f_owner.
Input (facts is 10823 characters; the first 1399 are shown):
{
"task": "rewrite",
"marketplace": "Shopify App Store (shopify), live; classification data_rich (a real view-to-install funnel)\nFields (id | indexed | claim | limit): name | indexed | shp_name_subtitle (single_source) | none in the references; subtitle | indexed | shp_name_subtitle (single_source) | none in the references; search_terms | indexed | shp_search_terms (vendor_official) | up to five terms†, one concept per term; description | not known to be indexed | - | none in the references\nTitle convention: brand-first (shp_brand_first, vendor_official)\nVideo ceiling: 2-3 minutes†, promotional tone allowed, screencast capped at 25% of runtime†",
"listing": "name (App name): Invoice Sync & Bookkeeping - Tallywren\nsubtitle (Subtitle): Sync invoices to your books in one click\nsearch_terms (Search terms, one per line): invoices / bookkeeping software for small stores / tax / reports / exports / receipts\ndescription (Long description): Tallywren keeps your store's books current. Payout reconciliation matches every payout to its bank deposit, multi-currency orders convert at the daily rate, and receipts attach to each entry. It works with the tools your accountant already uses.",
"terms": "- invoice sync | support tickets\n- quickbooks sync | support tickets\n- sales tax report | search-term report\n- bookkeeping | sales calls\n- multi currency | sales calls\n- payout reconciliation | search-term report",
"gallery": "#1 | screenshot | Plans and pricing compared | shows pricing\n#2 | screenshot | Every invoice synced to your ledger | hero\n#3 | screenshot | Every invoice synced to your ledger | duplicate of #2\n#4 | logo | Tallywren logo | logo only\n#5 | screenshot | Sales tax report by region | most-loved\n#6 | screenshot | Payouts matched to bank deposits | differentiator\nDemo video: 240 seconds",
"reviews": "Reviews as of 2026-11-02: count 8; average 3.9; newest 2026-07-01 (124 days old); trailing 90 days 0\nNewest ten dates: 2026-07-01, 2026-06-12, 2026-05-20, 2026-04-02, 2026-02-14, 2025-12-01, 2025-10-11, 2025-08-30",
"context": "Brand: Tallywren; maturity: an unknown entrant (challenger)\nAnalytics on: none\nFunnel: views 18,400; installs 212\nBadge: none; procurement gates on a badge: no\nReview asks in use: lifecycle (a lifecycle email to all active users); incentive (discounts, gift cards or feature unlocks for reviews)\nUninstall trend: rising; owner: none; last change: 2026-05-10\nDeadline: open-ended; mandate: a compounding asset; effort ceiling: medium\nUnfair advantages: none",
"carry": "",
"facts": "The page's checks (12 fail, 12 warn, 4 pass, 9 info, 1 n/a); verdict: not yet - fails to fix first; area | id | subject | status | evidence [claim, label]:\n- instrument | i_class | - | info | data-rich: a real view-to-install funnel\n- instrument | i_native | - | fail | off - turn on the partner dashboard: installs, uninstalls, net installs, ratings and revenue before touching content; an optimization you cannot measure is a guess\n- instrument | i_ledger | - | na | not needed while native dashboards exist\n- instrument | i_external | - | info | off - GA4 Measurement ID and Meta Pixel wiring (Distribution, Manage listing, Tracking), which passes server-side install events; it gives the full listing-view, install-click, install funnel - wire it once weekly decisions depend on funnel data\n- instrument | i_funnel | - | info | 212 installs from 18,400 listing views (1.2% view-to-install)\n- keywords | k_terms | - | info | 6 buyer terms from support tickets, search-term report, sales calls\n- keywords | k_field | name | pass | App name carries invoice sync, bookkeeping [shp_name_subtitle, single_source]\n- keywords | k_field | subtitle | fail | Subtitle carries none of the buyer terms [shp_name_subtitle, single_source]\n- keywords | k_field | search_terms | pass | Search terms, one per line carries bookkeeping [shp_search_terms, vendor_official]\n- keywords | k_coverage | - | fail | 2 of 6\n\n[... 9424 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "rewrite",
"summary": "A brand-first name, buyer terms in the subtitle and one-concept search terms, then three unique in-UI screens first; the indexed fields now cover five of the six buyer terms.",
"fields": [
{
"field": "name",
"text": "Tallywren Invoice Sync",
"terms": [
"invoice sync"
]
},
{
"field": "subtitle",
"text": "QuickBooks sync, sales tax report and payout reconciliation for your store",
"terms": [
"quickbooks sync",
"sales tax report",
"payout reconciliation"
]
},
{
"field": "search_terms",
"text": "invoice sync\nquickbooks sync\nsales tax report\nmulti-currency\nbookkeeping",
"terms": [
"invoice sync",
"quickbooks sync",
"sales tax report",
"multi-currency",
"bookkeeping"
]
}
],
"gallery": [
{
"n": 2,
"caption": "Every invoice synced to your ledger",
"position": 1
},
{
"n": 6,
"caption": "Payouts matched to bank deposits",
"position": 2
},
{
"n": 5,
"caption": "Sales tax report by region",
"position": 3
}
],
"video": {
"decision": "add",
"why": "Replace the current cut with a shorter one inside Shopify's 2-3 minutes† ceiling that shows invoices syncing, not marketing."
},
"claims": [
{
"claim": "shp_brand_first",
"label": "vendor_official"
},
{
"claim": "shp_search_terms",
"label": "vendor_official"
},
{
"claim": "shp_name_subtitle",
"label": "single_source"
},
{
"claim": "ana_order",
"label": "analogy"
}
],
"fixes": [
{
"check": "k_field",
"subject": "subtitle",
"change": "Put quickbooks sync, sales tax report and payout reconciliation in the subtitle, which carries none of the buyer terms today."
},
{
"check": "k_coverage",
"subject": "",
"change": "Carry five of the six buyer terms in the indexed fields instead of two, so coverage clears the page's own threshold."
},
{
"check": "k_limit",
"subject": "search_terms",
"change": "Keep one concept per line and drop tax, reports, exports and receipts so the field holds no more than Shopify allows."
},
{
"check": "t_convention",
"subject": "",
"change": "Start the name with Tallywren and follow it with invoice sync, as Shopify's brand-first convention instructs."
},
{
"check": "g_first3_unique",
"subject": "",
"change": "Lead with #2, #6 and #5 so the first three assets are three different screens."
}
],
"footnote": "† verify against the marketplace's current listing rules before relying on it - all five platforms revise them on an ongoing basis.",
"caveats": [
"The name and subtitle carrying about 80%† of keyword ranking weight is one tracker's estimate, so expect direction, not magnitude.",
"Turn on the partner dashboard before shipping this, or the rewrite cannot be measured."
]
}
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: k_field subtitle | k_coverage | k_limit search_terms | t_convention | g_first3_unique | g_claims #1 | g_duplicate #3 | g_logo_only #4 | g_video
The page's check: kept - nothing to flag
program - Tallywren on Shopify - fails a lot
The input carries the saved rewrite (R.laneForm fills carry from the rewrite reply), as the page's handoff does. Shopify App Store, data_rich; the triggers: platform_triggers add, post_support add, milestone_ask add, lifecycle_email use, negative_replies add; gates: g_instrument fail, g_keywords fail, g_gallery_asks fail, g_provenance pass, g_refresh fail.
Input (facts is 10986 characters; the first 1399 are shown):
{
"task": "program",
"marketplace": "Shopify App Store (shopify), live; classification data_rich (a real view-to-install funnel)\nFields (id | indexed | claim | limit): name | indexed | shp_name_subtitle (single_source) | none in the references; subtitle | indexed | shp_name_subtitle (single_source) | none in the references; search_terms | indexed | shp_search_terms (vendor_official) | up to five terms†, one concept per term; description | not known to be indexed | - | none in the references\nTitle convention: brand-first (shp_brand_first, vendor_official)\nVideo ceiling: 2-3 minutes†, promotional tone allowed, screencast capped at 25% of runtime†",
"listing": "name (App name): Invoice Sync & Bookkeeping - Tallywren\nsubtitle (Subtitle): Sync invoices to your books in one click\nsearch_terms (Search terms, one per line): invoices / bookkeeping software for small stores / tax / reports / exports / receipts\ndescription (Long description): Tallywren keeps your store's books current. Payout reconciliation matches every payout to its bank deposit, multi-currency orders convert at the daily rate, and receipts attach to each entry. It works with the tools your accountant already uses.",
"terms": "- invoice sync | support tickets\n- quickbooks sync | support tickets\n- sales tax report | search-term report\n- bookkeeping | sales calls\n- multi currency | sales calls\n- payout reconciliation | search-term report",
"gallery": "#1 | screenshot | Plans and pricing compared | shows pricing\n#2 | screenshot | Every invoice synced to your ledger | hero\n#3 | screenshot | Every invoice synced to your ledger | duplicate of #2\n#4 | logo | Tallywren logo | logo only\n#5 | screenshot | Sales tax report by region | most-loved\n#6 | screenshot | Payouts matched to bank deposits | differentiator\nDemo video: 240 seconds",
"reviews": "Reviews as of 2026-11-02: count 8; average 3.9; newest 2026-07-01 (124 days old); trailing 90 days 0\nNewest ten dates: 2026-07-01, 2026-06-12, 2026-05-20, 2026-04-02, 2026-02-14, 2025-12-01, 2025-10-11, 2025-08-30",
"context": "Brand: Tallywren; maturity: an unknown entrant (challenger)\nAnalytics on: none\nFunnel: views 18,400; installs 212\nBadge: none; procurement gates on a badge: no\nReview asks in use: lifecycle (a lifecycle email to all active users); incentive (discounts, gift cards or feature unlocks for reviews)\nUninstall trend: rising; owner: none; last change: 2026-05-10\nDeadline: open-ended; mandate: a compounding asset; effort ceiling: medium\nUnfair advantages: none",
"carry": "The rewrite's indexed fields (field: text):\n- name: Tallywren Invoice Sync\n- subtitle: QuickBooks sync, sales tax report and payout reconciliation for your store\n- search_terms: invoice sync / quickbooks sync / sales tax report / multi-currency / bookkeeping\nThe rewrite's first screen: #2, #6, #5\nThe rewrite's claims: shp_brand_first (vendor_official), shp_search_terms (vendor_official), shp_name_subtitle (single_source), ana_order (analogy)",
"facts": "The page's checks (12 fail, 12 warn, 4 pass, 9 info, 1 n/a); verdict: not yet - fails to fix first; area | id | subject | status | evidence [claim, label]:\n- instrument | i_class | - | info | data-rich: a real view-to-install funnel\n- instrument | i_native | - | fail | off - turn on the partner dashboard: installs, uninstalls, net installs, ratings and revenue before touching content; an optimization you cannot measure is a guess\n- instrument | i_ledger | - | na | not needed while native dashboards exist\n- instrument | i_external | - | info | off - GA4 Measurement ID and Meta Pixel wiring (Distribution, Manage listing, Tracking), which passes server-side install events; it gives the full listing-view, install-click, install funnel - wire it once weekly decisions depend on funnel data\n- instrument | i_funnel | - | info | 212 installs from 18,400 listing views (1.2% view-to-install)\n- keywords | k_terms | - | info | 6 buyer terms from support tickets, search-term report, sales calls\n- keywords | k_field | name | pass | App name carries invoice sync, bookkeeping [shp_name_subtitle, single_source]\n- keywords | k_field | subtitle | fail | Subtitle carries none of the buyer terms [shp_name_subtitle, single_source]\n- keywords | k_field | search_terms | pass | Search terms, one per line carries bookkeeping [shp_search_terms, vendor_official]\n- keywords | k_coverage | - | fail | 2 of 6\n\n[... 9587 more characters cut from this display ...]"
}
Reply:
{
"task": "program",
"summary": "Turn on the partner dashboard, stop the discount for reviews, add the Reviews API and post-support asks, and refresh the listing every 45 days under a named marketplace owner.",
"classification": "data_rich",
"triggers": [
{
"trigger": "platform_triggers",
"status": "add",
"how": "Route in-product review prompts through Shopify's Reviews API at a natural moment that never blocks the merchant's work."
},
{
"trigger": "post_support",
"status": "add",
"how": "Ask after each positive support interaction, at a natural pause, with an opt-out from future asks."
},
{
"trigger": "milestone_ask",
"status": "add",
"how": "A named person asks a specific merchant at a success milestone, such as the first month of reconciled payouts."
},
{
"trigger": "lifecycle_email",
"status": "use",
"how": "Keep the lifecycle email unconditional and send it to every qualifying active merchant, with nothing attached."
},
{
"trigger": "negative_replies",
"status": "add",
"how": "Reply publicly once to each negative review, aiming to fix the issue and get the review amended."
}
],
"deleted": [
{
"tactic": "incentives",
"stop": "Stop the discount offered for a review today, in the app and in every email, and never bring it back to rescue a slow month."
}
],
"ask": "Thanks for keeping your books with Tallywren.\nIf you have a minute, an honest review on the listing helps other merchants decide.\nYou can turn these requests off in settings.",
"cadence": {
"cycle_days": 45,
"owner": "marketplace owner",
"next": "2026-11-02"
},
"kpis": [
{
"kpi": "views_conversion",
"now": "1.2% (212 installs from 18,400 views)",
"where": "the partner dashboard, once it is on"
},
{
"kpi": "keyword_coverage",
"now": "33% (2 of 6 terms)",
"where": "this page, each refresh cycle"
},
{
"kpi": "review_velocity",
"now": "0 reviews in 90 days",
"where": "the listing's review history"
},
{
"kpi": "average_rating",
"now": "3.9 of 5 over 8 reviews",
"where": "the listing"
},
{
"kpi": "uninstall_rate",
"now": "rising",
"where": "the partner dashboard's uninstalls"
},
{
"kpi": "badge_progress",
"now": "not started",
"where": "the Built for Shopify criteria"
}
],
"fixes": [
{
"check": "i_native",
"subject": "",
"change": "Turn on the partner dashboard now, before the rewrite ships, so installs, uninstalls and ratings show what it changed."
},
{
"check": "f_owner",
"subject": "",
"change": "Name one marketplace owner who runs the 45-day refresh and the review program."
}
],
"claims": [
{
"claim": "gen_velocity",
"label": "corroborated"
},
{
"claim": "shp_uninstall",
"label": "corroborated"
},
{
"claim": "gen_email",
"label": "corroborated"
}
],
"footnote": "† verify against the marketplace's current listing rules before relying on it - all five platforms revise them on an ongoing basis.",
"caveats": [
"Uninstalls are rising and uninstall rate feeds ranking negatively, so fix the product gap before pushing for installs.",
"Review recency and velocity outweigh lifetime totals, so the empty last 90 days is the number to move first."
]
}
What step 5 prints for it: platform_triggers add | post_support add | milestone_ask add | lifecycle_email use | negative_replies add
The page's check: kept - nothing to flag
program - Murmeldesk on Slack - data-dark, pre-submission
The input carries the saved rewrite (R.laneForm fills carry from the rewrite reply), as the page's handoff does. Slack Marketplace, data_dark; the triggers: platform_triggers skip, post_support skip, lifecycle_email skip, milestone_ask skip, negative_replies skip; gates: g_instrument fail, g_keywords fail, g_gallery_asks fail, g_provenance pass, g_refresh pass.
Input (facts is 7459 characters; the first 1394 are shown):
{
"task": "program",
"marketplace": "Slack Marketplace (slack), pre-submission; classification data_dark (no analytics, no review system)\nFields (id | indexed | claim | limit): name | indexed | ana_fields (analogy) | none in the references; short_description | indexed | ana_fields (analogy) | none in the references; description | not known to be indexed | - | none in the references\nTitle convention: none documented in the references\nVideo ceiling: 30-90 seconds†",
"listing": "name (App name): Murmeldesk\nshort_description (Short description): Run your team's daily standup without a meeting.\ndescription (Long description): Async standup threads, team check-in reminders and a weekly digest for managers. Answers stay in one thread per day.",
"terms": "- async standup | support tickets\n- daily standup | sales calls\n- team check in | sales calls\n- standup bot | sales calls",
"gallery": "#1 | screenshot | One standup thread with every answer | hero\n#2 | screenshot | Reminder schedule per team | -\n#3 | logo | Murmeldesk logo | -\nDemo video: none",
"reviews": "No review system on Slack Marketplace.",
"context": "Brand: Murmeldesk; maturity: an unknown entrant (challenger)\nAnalytics on: none\nFunnel: views not given; installs not given\nBadge: none; procurement gates on a badge: no\nReview asks in use: lifecycle (a lifecycle email to all active users); selective (asking only happy users)\nUninstall trend: unknown; owner: Growth PM; last change: not given\nDeadline: a hard date (2026-12-01); mandate: a one-off win; effort ceiling: low\nUnfair advantages: none",
"carry": "The rewrite's indexed fields (field: text):\n- name: Murmeldesk - async standup and team check-in\n- short_description: A standup bot for your daily standup: async standup threads and team check-in reminders in one place.\nThe rewrite's first screen: #1, #2, #3\nThe rewrite's claims: ana_fields (analogy), ana_order (analogy)",
"facts": "The page's checks (5 fail, 5 warn, 4 pass, 7 info, 6 n/a); verdict: not yet - fails to fix first; area | id | subject | status | evidence [claim, label]:\n- instrument | i_class | - | info | data-dark: no listing analytics and no review system - write the best listing once, drive traffic from outside, self-track installs, skip the review program\n- instrument | i_native | - | na | the platform exposes no listing analytics to publishers\n- instrument | i_ledger | - | fail | no ledger - on a data-dark marketplace it is the only instrumentation that exists: your auth-token store as the install record plus the app-uninstalled event\n- instrument | i_funnel | - | na | no funnel exists to read\n- keywords | k_terms | - | info | 4 buyer terms from support tickets, sales calls\n- keywords | k_field | name | fail | App name carries none of the buyer terms [ana_fields, analogy]\n- keywords | k_field | short_description | pass | Short description carries daily standup [ana_fields, analogy]\n- keywords | k_coverage | - | fail | 1 of 4 buyer terms appear in an indexed field (25%; the page's own threshold is four terms in five) - coverage, not rank: where the listing ranks for them is a separate KPI\n- keywords | k_term | async standup | warn | only in long description, which this marketplace is not known to index\n- keywords | k_term | team check in | warn | only in long description, which this\n\n[... 6065 more characters cut from this display ...]"
}
Reply:
{
"task": "program",
"summary": "Slack is data-dark: skip the review program, build the install ledger from the token store and the uninstall event, stop the happy-users-only email, and refresh after launch.",
"classification": "data_dark",
"triggers": [
{
"trigger": "platform_triggers",
"status": "skip",
"how": "Slack has no review system, so there is no platform trigger to turn on."
},
{
"trigger": "post_support",
"status": "skip",
"how": "With no reviews on Slack, a post-support ask has nothing to ask for."
},
{
"trigger": "lifecycle_email",
"status": "skip",
"how": "Use lifecycle email for activation, not for review asks, on a marketplace without reviews."
},
{
"trigger": "milestone_ask",
"status": "skip",
"how": "Keep milestone conversations for product feedback; there is no listing review to collect."
},
{
"trigger": "negative_replies",
"status": "skip",
"how": "There are no public reviews on Slack to reply to."
}
],
"deleted": [
{
"tactic": "selective_asks",
"stop": "Stop the email that goes only to happy users and send product messages to every qualifying user instead."
}
],
"ask": "",
"cadence": {
"cycle_days": 30,
"owner": "Growth PM",
"next": "set it"
},
"kpis": [
{
"kpi": "views_conversion",
"now": "unknown",
"where": "the self-built install ledger"
},
{
"kpi": "keyword_coverage",
"now": "25% (1 of 4 terms)",
"where": "this page, each refresh cycle"
},
{
"kpi": "review_velocity",
"now": "unknown",
"where": "none - no review system"
},
{
"kpi": "average_rating",
"now": "unknown",
"where": "none - no review system"
},
{
"kpi": "uninstall_rate",
"now": "unknown",
"where": "the app-uninstalled event"
},
{
"kpi": "badge_progress",
"now": "unknown",
"where": "none - no badge named for Slack"
}
],
"fixes": [
{
"check": "i_ledger",
"subject": "",
"change": "Record every install from your auth-token store and subscribe to the app-uninstalled event before submission."
}
],
"claims": [],
"footnote": "† verify against the marketplace's current listing rules before relying on it - all five platforms revise them on an ongoing basis.",
"caveats": [
"Set the first refresh date once the listing is live.",
"Slack watches usage and uninstalls itself and contacts you, so keep the ledger honest."
]
}
What step 5 prints for it: platform_triggers skip | post_support skip | lifecycle_email skip | milestone_ask skip | negative_replies skip
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./llkit.js"); require("./llreply.js")), then:
const K = LlKit, R = LlReply;
const f = K.form(Object.assign(K.blank(), { market: "hubspot", brand: "Contaquill", name: "Contaquill - CRM sync", tagline: "Two-way contact sync",
terms: "crm sync | support tickets\nsync | sales calls\ncontact dedupe | sales calls", gallery: "#1 | screenshot | Contacts synced | hero" }));
// f.terms (normalized buyer terms), f.matrix[term][field] (whole-word counts), f.coverage, f.gallery, f.rev (count, average,
// newest, trailing 90 days), f.rows (the checks, each with a claim id and its provenance label), f.instr, f.opt and f.trig
// (the ladders), f.badgeV, f.gates, f.kpis, f.cadence, f.contradictions (always empty for a settled board)
// K.coverageCsv(f), K.checksCsv(f), K.galleryCsv(f), K.reviewsMd(f), K.board(f), K.applyPlan(f) - the exports
const b = K.buildInput(f, "rewrite"); // or "program" (R.laneForm(form, "program", rewriteReply) fills carry)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "rewrite");
// 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://listing-lift.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-lift.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-lift.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://listing-lift.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-lift.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
}
// ListingLift.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 ListingLift {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://listing-lift.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-lift.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-lift.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-lift.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-lift"}' | 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-lift"})["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-lift" })).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-lift"}, 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-lift"), 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-lift" })["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-lift"])["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-lift" }))!["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": "rewrite", "marketplace": "Shopify App Store (shopify), live; classification data_rich ...", "listing": "name (App name): Invoice Sync & Bookkeeping - Tallywren ...", "terms": "- invoice sync | support tickets ...", "gallery": "#1 | screenshot | Plans and pricing compared | shows pricing ...", "reviews": "Reviews as of 2026-11-02: count 8 ...", "context": "Brand: Tallywren; maturity: an unknown entrant ...", "carry": "", "facts": "The page's checks (12 fail, ...); area | id | subject | status | evidence [claim, label]: ..."}
# - 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": "rewrite", "marketplace": "Shopify App Store (shopify), live; classification data_rich ...", "listing": "name (App name): Invoice Sync & Bookkeeping - Tallywren ...", "terms": "- invoice sync | support tickets ...", "gallery": "#1 | screenshot | Plans and pricing compared | shows pricing ...", "reviews": "Reviews as of 2026-11-02: count 8 ...", "context": "Brand: Tallywren; maturity: an unknown entrant ...", "carry": "", "facts": "The page's checks (12 fail, ...); area | id | subject | status | evidence [claim, label]: ..."}
# - 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: "rewrite" or "program", marketplace, listing, terms, gallery, reviews, context, 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-lift:<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 rewrite reply carries summary, fields, gallery, video, claims, fixes, footnote and caveats; a program reply carries summary, classification, triggers, deleted, ask, cadence, kpis, fixes, claims, footnote and caveats. The code below prints the gist of either: each fix of the rewrite with its check, or each trigger with its status.
KEY="listing-lift:$(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 == "program" then (.triggers[] | "\(.trigger) \(.status) -> \(.how)")
else (.fixes[] | "\(.check) \(.subject) \(.change)") end' reply.json
import hashlib, time
key = "listing-lift:" + 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"] == "program":
for x in reply["triggers"]:
print(x["trigger"], x["status"], "->", x["how"])
print(reply["classification"], reply["cadence"]["next"])
else:
for x in reply["fixes"]:
print(x["check"], x["subject"], x["change"])
import { createHash } from "node:crypto";
const key = `listing-lift:${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 === "program") { for (const x of reply.triggers) console.log(x.trigger, x.status, "->", x.how); console.log(reply.classification, reply.cadence.next); }
else { for (const x of reply.fixes) console.log(x.check, x.subject, x.change); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "listing-lift:" + 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"] == "program" {
key, name, field = "triggers", "trigger", "how"
}
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 = "listing-lift:" + 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("program")) {
for (JsonNode x : reply.path("triggers")) System.out.println(x.path("trigger").asText() + " " + x.path("status").asText() + " -> " + x.path("how").asText());
} else {
for (JsonNode x : reply.path("fixes")) System.out.println(x.path("check").asText() + " " + x.path("change").asText());
}
require "digest"
key = "listing-lift:#{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"] == "program"
reply["triggers"].each { |x| puts "#{x["trigger"]} #{x["status"]} -> #{x["how"]}" }
else
reply["fixes"].each { |x| puts "#{x["check"]} #{x["change"]}" }
end
$key = "listing-lift:" . $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"] === "program") { foreach ($reply["triggers"] as $x) echo $x["trigger"], " ", $x["status"], " -> ", $x["how"], "\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 = $"listing-lift:{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 == "program") { foreach (var x in reply["triggers"]!.AsArray()) Console.WriteLine($"{x!["trigger"]} {x["status"]} -> {x["how"]}"); }
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: listing-lift:$(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": "rewrite", "marketplace": "Shopify App Store (shopify), live; classification data_rich ...", "listing": "name (App name): Invoice Sync & Bookkeeping - Tallywren ...", "terms": "- invoice sync | support tickets ...", "gallery": "#1 | screenshot | Plans and pricing compared | shows pricing ...", "reviews": "Reviews as of 2026-11-02: count 8 ...", "context": "Brand: Tallywren; maturity: an unknown entrant ...", "carry": "", "facts": "The page's checks (12 fail, ...); area | id | subject | status | evidence [claim, label]: ..."}
# - 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-lift:" + 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-lift:${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-lift:"+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-lift:" + 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-lift:#{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-lift:" . 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-lift:{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);