Run AppSource Ready from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page reads the Teams manifest, parses the plans, builds the locked-choice register, runs every check, compares the plans with the plans as published, re-ranks the path ladder, sets the co-sell target and lays out the timeline from the target date, then hands it all to the run as facts. The run plans the listing or answers a validation report; the page then holds it to its own facts (msreply.js). AppSource Ready is not affiliated with or endorsed by Microsoft.
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":"appsource-ready"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required key (task, product, offer, setup, facts) is missing. The page always sends all eight of task, product, manifest, offer, setup, report, carry, facts; report and carry are empty strings for plan, and manifest is empty when no manifest was pasted. |
| 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 a validation report:
plan- the listing planned: the page's top rung of the path ladder, exactly one locked row per register row (pricing model, plan id, seat limits, dimensions, contract, plan visibility - owner "unassigned" and date "before first publish" unless the input gives them), one fix per failing check, the submission split by object (Teams app, SaaS offer, co-sell), the page's co-sell target with its gap, and the timeline with a dagger on every Microsoft figure.fixes- a validation report answered: one fix per item the report raises, in its order, each with the report's severity (Must fix, Good-to-fix, Blocker, Query or unlabelled), the re-validation effect (a manifest, screenshot or Partner Center change re-enters validation; a web-service-only change does not), then the reply to the reviewer - one point per line, never arguing, never asking to expedite. Needs the report text inreport.
A missing or unknown task is answered as plan. Both lanes send the same eight fields. The facts are the page's checks, one row per line, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the plan's path, register, fixes and submission into carry for the fixes lane.
The input
| Field | Required | What |
|---|---|---|
task | yes | plan or fixes. If it is missing or neither, do plan and set "task": "plan". |
product | yes | what the product does and the interview answers: the Microsoft 365 surface, the hosting and the Microsoft integration, the offer type and the MACC buyers, the stage and whether the offer is published, the target date with its days from today, Publisher Verification and Attestation, Frontier Accelerate, the threshold, the architecture diagram, the test accounts, regulated buyers and a planned renewal private offer. |
manifest | send it (empty when no manifest was pasted) | the Teams manifest.json as the page read it, field by field (links described, never copied), or empty when none was pasted. |
offer | yes | the offer and its plans as the page parsed them (id, name, visibility, pricing, seats, price, dimensions), then the plans as published when the offer is published. |
setup | yes | the landing page and the webhook described (given, https, contains #), the Entra app, the permissions requested at activation and the notes for certification as pasted. |
report | send it (the validation report for fixes, empty for plan) | fixes task only: the validation report text as pasted. |
carry | send it (empty for plan) | fixes task only: the listing plan's path, register, fixes and submission when the plan ran first, and the previous resubmission's points once one is logged; may be empty. |
facts | yes | the page's checks, one line each with the rule, the id, the status and the evidence separated by vertical bars; then the locked-choice register, the comparison with the plans as published, the path ladder, the starved option, the co-sell target and its gap, the timeline, the submission packet, the stage, the figures and the footnote. A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it and reply with only the JSON object. |
The rules the model applies
From the source skill, @samber/microsoft-appsource-playbook - the only standard the run applies:
- Scope: a vendor taking a SaaS product onto Microsoft Marketplace and the Teams store. Not listing copy or search rank, not running your own marketplace, not Teams bot code, not Windows Store apps, not services co-sell, not spending MACC as a buyer.
- Three objects get three separate reviews: the Teams app (the Microsoft 365 and Copilot program, which also covers Office add-ins and Copilot agents), the SaaS offer, and the co-sell solution. Never confuse them.
- Teams apps never earn co-sell: Microsoft excludes apps and add-ins for Teams, Outlook and Excel from co-sell-ready status. The SaaS offer linked to the app stays eligible: co-sell and MACC run on the offer.
- Non-Azure SaaS lists only with a Microsoft integration: an offer not primarily on Azure must integrate with or extend a Microsoft product, say how in the description, and accept Entra SSO; it is not purchasable in the Azure management console. Primarily means the Azure-hosted compute or data plane grows fastest with usage.
- Only transact counts: Contact me, listing-only trials, Get It Now (Free) and BYOL unlock no MACC, no IP co-sell, no private offers; any live SaaS offer can still reach co-sell ready.
- MACC needs Azure IP co-sell eligible: solution type IP, primarily on Azure with technical validation, a reference architecture diagram, "Sell through Microsoft", and $100,000† Azure consumed revenue in the trailing 12 months† or $100,000† billed through the offer. A $1M+† MACC customer route is in pilot. MACC counts per offer and only for usage running in Azure. Co-sell ready is visibility, not pipeline: sellers act on quota, so the vendor brings its own opportunities.
- Frontier Accelerate for Marketplace absorbed ISV Success, Marketplace Rewards and Azure IP co-sell; enroll before any co-sell listing.
- Locked once the SaaS offer or plan publishes: the pricing model (flat rate or per user, the same for every plan in the offer), the plan ID, the per-user seat minimum and maximum, metered dimension IDs and values, a public plan going private, standard contract versus your own terms. Metering exists only on flat-rate plans, and Teams-linked offers must be per user. Changing a locked choice needs a new offer or plan.
- The path, by pipeline per engineering week: transact SaaS + private offers > + Teams app linked > + Azure IP co-sell eligible > Teams app alone > Contact me. Another cloud deletes IP co-sell and stops at co-sell ready; with no Microsoft integration the SaaS offer is deleted too. A hard date promotes the Teams app and private offers; a compounding enterprise mandate promotes IP co-sell. The starved option, Microsoft 365 Certification (a yearly third-party audit with pen test), pays only when regulated buyers' security reviews stall deals.
- Ship the SaaS offer: the Partner Center account, the tax profile validated (up to 48 h†) before the payout profile; a multitenant Entra app accepting work and personal Microsoft accounts, only User.Read requested during activation; the landing page always up, no # in the URL, no secrets shown, no downloads, the purchase token valid 24 hours†; every webhook call validated through the Get Operation API, plan and seat changes answered within 10 seconds†; metering sent hourly†, events older than 24 hours† rejected; notes for certification with test accounts without MFA or developer interaction and purchase-to-activation steps (missing notes are a top failure); test on a separate DEV offer and never press Go live on it (test purchases are refunded only when cancelled within 72 hours†).
- Pass Teams validation: Publisher Verification first (it is Must fix); a validation report targets 24 working hours† and labels issues Must fix, Good-to-fix, Blocker or Query; fix, reply, wait for confirmation, resubmit; budget three to six weeks†, Office add-ins 3-4 business days† per round and four to six weeks† end to end. Publisher Attestation the day the app goes live, renewed yearly; an already listed app attests before each update. Linking needs a live SaaS offer: add subscriptionOffer (publisherId.offerId) once the offer is live, and that update goes through validation again. Any manifest, screenshot or Partner Center change re-enters validation; web-service-only changes do not. Channel-enabled apps need schema 1.25† or later for new submissions. Names never use Teams or Microsoft, or Beta, Dev, Preview or UAT.
- Private offers: take every first deal as a private offer. Tick the renewal attestation when creating a private offer for a renewal, a migrated contract or an upsell: the fee halves† for the whole term, it cannot be added after creation, and an offer created before October 2024† never qualifies. The marketplace fee is 3%†. After IP co-sell eligibility, the offer shows MACC Enrolled within about a week†.
- Mark every Microsoft figure you quote with † right after it, and close with the footnote: "† verify in Partner Center before quoting it, since Microsoft renames programs and moves criteria yearly."
House rules
- Use only the input. Never invent an id, a URL, a price, a threshold, a date, a person or a review outcome; the only figures you may quote are the skill's, each with †. When the input does not say, write "not stated in the input".
- The page's verdicts are settled: every check status, the top rung of the path ladder and the co-sell target. Never move them; explain them.
- Every Microsoft figure carries † right after it, and the
footnotefield is exactly the skill's footnote. - Never imply that Microsoft certifies, endorses, approves or recommends the product, and never write a phrase that could be read that way.
- No URL: never write a scheme such as https and never copy a link. Write "the landing page", "the webhook" or "your privacy link" where a link belongs.
- Write in the language of the input; keep the JSON keys, check ids, plan ids, dimension 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
plan
Plan the listing: the path, the locked-choice register, one fix per failing check, the submission, the co-sell target and the timeline.
summary- one sentence, at most 40 words: what stands between the offer and its first Marketplace transaction today and what the plan changes.path- an object:rung(exactly the facts' top rung:transact_private,teams_linked,ip_cosell,teams_onlyorcontact_me),why(one or two sentences naming the interview answer behind it and what moved the ladder).locked- exactly one object per row of the facts' register, in its order:choice(exactly the row's choice:pricing model,plan id,seat limits,dimensions,contractorplan visibility),decision(the decision as the input gives it, naming the plan id or the dimension id of that row, or "undecided"),owner(exactly "unassigned" unless the input names a role for it),by(a date only when the input gives one; exactly "already published" when the offer is published; otherwise exactly "before first publish"). No other row.fixes- one object per check the facts mark fail, and any warn worth fixing:check(the page's check id),change(one concrete sentence: what to change in the manifest, the plans, Partner Center, the setup or the notes for certification). Every fail check appears. When a fix touches a locked choice of a published offer, say it needs a new offer or plan.submission- the submission plan, one object per item:item(one sentence),object(teams_app,saas_offerorcosell),done(true or false, as the facts' packet and checks say).cosell- an object:status(exactly the facts' co-sell target:not_targeted,cosell_readyorip_cosell_eligible),gap(one sentence: what is missing, from the facts' gap, or "none").timeline- the steps to the first Marketplace transaction and beyond, each an object:step(one sentence),wait(the wait, with † on every Microsoft figure); one failed validation cycle included.footnote- exactly the skill's footnote.caveats- at most 4 short strings: what the team must confirm;[]when there is nothing to say.
{
"task": "plan",
"summary": "one sentence",
"path": { "rung": "transact_private", "why": "one or two sentences" },
"locked": [ { "choice": "pricing model", "decision": "per user", "owner": "unassigned", "by": "before first publish" } ],
"fixes": [ { "check": "teams_pricing", "change": "one sentence" } ],
"submission": [ { "item": "one sentence", "object": "saas_offer", "done": false } ],
"cosell": { "status": "not_targeted", "gap": "one sentence" },
"timeline": [ { "step": "one sentence", "wait": "three to six weeks†" } ],
"footnote": "† verify in Partner Center before quoting it, since Microsoft renames programs and moves criteria yearly.",
"caveats": [ ]
}
The page holds it to its own facts (check() in msreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL with a scheme; no backticks; no implied Microsoft endorsement; no price the input does not give; a summary of at most 40 words; every Microsoft figure with its dagger and the exact footnote; the page's top rung and co-sell target (a different one is put back and flagged); exactly as many locked rows of each choice as the register has, none invented, every plan ID and dimension ID named, the pricing model as the input gives it, an owner of "unassigned" unless the input names that role and a by of "before first publish" ("already published" for a published offer) unless the input gives the date; a fix for every fail check and only the page's check ids; submission items with an object of teams_app, saas_offer or cosell and a done that does not contradict the page's packet; a timeline.
fixes
Answer the validation report: one fix per item the report raises, the re-validation effect, then the reply to the reviewer.
summary- one sentence, at most 40 words: what the report returned and what the resubmission changes.points- one object per item the report raises, in its order:n(1, 2, 3...),report(a short quote or paraphrase of the item, from the report),severity(exactly the report's label for that item:must_fix,good_to_fix,blockerorquery;unlabelledwhen the report gives none),fix(the concrete change, one sentence; for a Query, the answer you give),check(the page's check id the item matches, or ""). When the carried plan has a fix for that check, use it.resubmit- an object:object(teams_app,saas_offerorcosell- the object the report reviewed),effect(one sentence on re-validation: any manifest, screenshot or Partner Center change re-enters validation; web-service-only changes do not - say which applies to these fixes),when(one sentence: reply first, wait for confirmation, then resubmit once with every fix in one pass).reply- the reply to the reviewer: thanks them, then each point on its own line with its fix, numbered in the report's order, then a closing line. Separate the lines with a newline, written \n inside the JSON string - for example "Thank you for the review.\n1. Publisher Verification is now complete.\n2. The name no longer uses Teams.\nWe will resubmit once you confirm." Polite, never argues, never asks to expedite, never claims endorsement, no URL.footnote- exactly the skill's footnote.caveats- at most 4 short strings: what to confirm before resubmitting;[]when there is nothing to say.
{
"task": "fixes",
"summary": "one sentence",
"points": [ { "n": 1, "report": "short quote or paraphrase", "severity": "must_fix", "fix": "one sentence", "check": "publisher_verification" } ],
"resubmit": { "object": "teams_app", "effect": "one sentence", "when": "one sentence" },
"reply": "Thank you for the review.\n1. first point - its fix\n2. second point - its fix\nWe will resubmit once you confirm.",
"footnote": "† verify in Partner Center before quoting it, since Microsoft renames programs and moves criteria yearly.",
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; the same text rules; at least as many points as the report numbers or labels, each item's severity exactly as the report labels it, only the page's check ids, a fix on each; object teams_app, saas_offer or cosell; an effect that says the resubmission re-enters validation whenever a fix touches the manifest, the screenshots or Partner Center, and never claims a web-service-only change re-enters it; a when; a reply that lists every point - numbered points count on their own lines or inline - never asks to expedite and never argues.
Worked examples
Three runs from the page's free samples (invented products, hosts on the reserved .example domain): the listing plan for a Teams-linked offer priced flat, the fixes for its validation report carrying that plan, and the plan for an offer already published on another cloud. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the long fields, strings and lists are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.
plan - A Teams-linked offer priced flat
The page read it as: returned as it stands; stage in validation; top rung transact_private; co-sell not_targeted; register rows pricing model 1, plan id 2, seat limits 0, dimensions 3, contract 1, plan visibility 2; fail checks: teams_pricing, landing_hash, entra_multitenant, cert_notes, test_accounts_mfa, publisher_verification, app_name, developer_urls; against the plans as published: not published.
Input (facts is 10336 characters; the first 1398 are shown):
{
"task": "plan",
"product": "What the product does: Tallyboard turns a team's tasks and channel activity into a weekly scorecard tab inside Teams and posts a Friday summary to the channel. Teams buy it in the Teams store; scorecard reports and exports are metered.\nMicrosoft 365 surface (answer 1): a Teams app\nHosting (answer 2): primarily Azure; integrates with a Microsoft product: yes\nPricing and buyers (answer 3): offer type transact; buyers hold Azure commitments (MACC): no\nStage (answer 4): in validation; offer already published: no\nTarget date for a first Marketplace transaction (answer 5): 2026-12-02 (30 days from today, 2026-11-02); a partner manager or sales lead owns Microsoft seller outreach: no\nPublisher Verification done: no; Publisher Attestation: not yet; Frontier Accelerate enrolled: no\nAzure consumed or billed through the offer in the trailing 12 months† reaches the $100,000† threshold: unknown; reference architecture diagram ready: no\nCertification test accounts without MFA: no; regulated buyers' security reviews stall deals: no; renewal, migrated or upsell private offer planned: no",
"manifest": "manifest.json, the fields the review reads:\nmanifestVersion 1.16; $schema missing; id given; version 1.0.0\nname.short: Tallyboard for Teams; name.full: Tallyboard for Teams - weekly team scorecards\ndeveloper: name Tallyboard Inc.; websiteUrl https; privacyUrl https; termsOfUseUrl missing; mpnId not given\ndescription.short: A weekly scorecard of your team's work, in a channel tab.\ncapabilities: 1 bot, 0 static tabs, 1 configurable tab, 0 message extensions, 0 declarative or custom agents; channel-enabled\nvalidDomains: *.tallyboard.example\nwebApplicationInfo: none\nRSC permissions: ChannelMessage.Read.Group (Application)\nsubscriptionOffer: tallyboardinc.tallyboard-teams",
"offer": "The offer as pasted: offer tallyboard-teams; pricing model flat rate; contract standard contract\nPlans (id | name | visibility | pricing | seats | price | dimensions):\n- starter | Starter | public | flat rate | no seat limits | price 99 USD per month | dimensions reports=0.05\n- business | Business | public | flat rate | no seat limits | price 499 USD per month | dimensions reports=0.03, exports=0.10\nThe offer is not published yet.",
"setup": "Landing page: given - https, contains #\nWebhook: given - https\nEntra app multitenant: no\nPermissions requested at activation: User.Read, Group.Read.All\nNotes for certification:\nnone given",
"report": "",
"carry": "",
"facts": "The page's checks (8 fail, 6 warn, 8 pass, 3 info, 12 n/a); verdict: returned as it stands; rule | id | status | evidence:\n- teams | teams_pricing | fail | the offer is flat rate but the manifest declares subscriptionOffer - the pricing model is fixed per offer, and a Teams link needs per user: a second offer, review and MACC enrollment; per-user plans carry no metered dimensions\n- teams | link_live | warn | subscriptionOffer is declared while the offer is in validation (answer 4) - linking needs a live SaaS offer; add it once live, and that update re-enters validation\n- teams | subscription_offer_id | pass | \"tallyboardinc.tallyboard-teams\" names the offer tallyboard-teams\n- locked | pricing_mixed | pass | every plan is flat rate\n- locked | metering_per_user | pass | metered dimensions only on flat-rate plans; metering is sent hourly† per resource and dimension, and events older than 24 hours† are rejected\n- locked | locked_pricing | na | the offer is not published yet (interview) - locked once it publishes\n- locked | locked_plan_id | na | the offer is not published yet (interview) - locked once it publishes\n- locked | locked_seats | na | the offer is not published yet (interview) - locked once it publishes\n- locked | locked_dimensions | na | the offer is not published yet (interview) - locked once it publishes\n- locked | locked_contract | na | the offer is not published yet\n\n[... 8938 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "plan",
"summary": "A flat-rate offer behind a Teams link, a landing page with #, a single-tenant Entra app, no certification notes and no Publisher Verification block the listing; the plan moves to per user before the first publish and fixes the rest.",
"path": {
"rung": "transact_private",
"why": "A transactable SaaS offer on Azure with private offers comes first; the target date in about a month promotes the Teams app and private offers, so the first deal goes through a private offer while the Teams app finishes validation."
},
"locked": [
{
"choice": "pricing model",
"decision": "flat rate today, which a Teams link cannot keep, so decide per user for every plan before the first publish",
"owner": "unassigned",
"by": "before first publish"
},
{
"choice": "plan id",
"decision": "starter (Starter)",
"owner": "unassigned",
"by": "before first publish"
},
{
"choice": "plan id",
"decision": "business (Business)",
"owner": "unassigned",
"by": "before first publish"
},
{
"choice": "dimensions",
"decision": "reports = 0.05 on starter, which per-user plans cannot keep",
"owner": "unassigned",
"by": "before first publish"
},
{
"choice": "dimensions",
"decision": "reports = 0.03 on business, which per-user plans cannot keep",
"owner": "unassigned",
"by": "before first publish"
}
],
"fixes": [
{
"check": "teams_pricing",
"change": "Set every plan to per user before the first publish, drop the reports and exports dimensions, and set a seat minimum and maximum per plan, because a Teams-linked offer must be per user and metering exists only on flat-rate plans."
},
{
"check": "landing_hash",
"change": "Serve activation from a landing page path with no # in the URL, keep it always up, and enter that address in Technical configuration."
},
{
"check": "entra_multitenant",
"change": "Register a multitenant Entra app that accepts work and personal Microsoft accounts and mint the API tokens with the tenant and app IDs entered in Technical configuration."
},
{
"check": "activation_permissions",
"change": "Request only User.Read during activation and ask for Group.Read.All after the subscription is active."
},
{
"check": "cert_notes",
"change": "Write the notes for certification with two test accounts and the purchase-to-activation steps from the Teams store to the landing page."
}
],
"submission": [
{
"item": "Publisher Verification for the Partner Center account.",
"object": "teams_app",
"done": false
},
{
"item": "The renamed package validated with the Teams developer tools and tested on desktop, web and mobile.",
"object": "teams_app",
"done": false
},
{
"item": "The terms of use link added and identical in the manifest.json and Partner Center.",
"object": "teams_app",
"done": false
},
{
"item": "Publisher Attestation on the day the app goes live.",
"object": "teams_app",
"done": false
},
{
"item": "A multitenant Entra app requesting only User.Read at activation.",
"object": "saas_offer",
"done": false
}
],
"cosell": {
"status": "not_targeted",
"gap": "none: no MACC buyers, no co-sell submission and nobody owning Microsoft seller outreach, so co-sell waits until one of those changes."
},
"timeline": [
{
"step": "Complete Publisher Verification and fix the name, the terms of use link, validDomains and the schema version in the package.",
"wait": "before resubmitting"
},
{
"step": "Resubmit the Teams app; the validation report targets 24 working hours† and the whole round, with one failed cycle, runs long.",
"wait": "three to six weeks†"
},
{
"step": "Rebuild the plans per user, fix the landing page and the Entra app, then publish the SaaS offer to a preview audience and Go live.",
"wait": "engineering weeks, not stated in the input"
},
{
"step": "Take the first deal as a private offer by the target date, without waiting for the Teams link.",
"wait": "by the target date"
},
{
"step": "Add subscriptionOffer once the offer is live and complete Publisher Attestation the day the app goes live.",
"wait": "after the offer is live, then one more validation"
}
],
"footnote": "† verify in Partner Center before quoting it, since Microsoft renames programs and moves criteria yearly.",
"caveats": [
"Confirm with the product owner that the metered reports and exports can become part of a per-user price.",
"The new app name is a suggestion; choose it before the next submission."
]
}
Cut from this display only (the saved reply and the check use the full text): locked (9 items, 5 shown); fixes (13 items, 5 shown); submission (9 items, 5 shown).
What step 5 prints for it: teams_pricing | landing_hash | entra_multitenant | activation_permissions | cert_notes | test_accounts_mfa | publisher_verification | app_name | developer_urls | manifest_schema | valid_domains | sso_app | link_live
The page's check: kept - nothing to flag
fixes - A Teams-linked offer priced flat
The input carries the saved plan (R.laneForm fills carry from the plan reply), as the page's handoff does, and the validation report, whose 4 items are labelled must_fix, must_fix, blocker, must_fix.
Input (carry is 3679 characters; the first 1393 are shown; facts is 10247 characters; the first 1398 are shown):
{
"task": "fixes",
"product": "What the product does: Tallyboard turns a team's tasks and channel activity into a weekly scorecard tab inside Teams and posts a Friday summary to the channel. Teams buy it in the Teams store; scorecard reports and exports are metered.\nMicrosoft 365 surface (answer 1): a Teams app\nHosting (answer 2): primarily Azure; integrates with a Microsoft product: yes\nPricing and buyers (answer 3): offer type transact; buyers hold Azure commitments (MACC): no\nStage (answer 4): in validation; offer already published: no\nTarget date for a first Marketplace transaction (answer 5): 2026-12-02 (30 days from today, 2026-11-02); a partner manager or sales lead owns Microsoft seller outreach: no\nPublisher Verification done: no; Publisher Attestation: not yet; Frontier Accelerate enrolled: no\nAzure consumed or billed through the offer in the trailing 12 months† reaches the $100,000† threshold: unknown; reference architecture diagram ready: no\nCertification test accounts without MFA: no; regulated buyers' security reviews stall deals: no; renewal, migrated or upsell private offer planned: no",
"manifest": "manifest.json, the fields the review reads:\nmanifestVersion 1.16; $schema missing; id given; version 1.0.0\nname.short: Tallyboard for Teams; name.full: Tallyboard for Teams - weekly team scorecards\ndeveloper: name Tallyboard Inc.; websiteUrl https; privacyUrl https; termsOfUseUrl missing; mpnId not given\ndescription.short: A weekly scorecard of your team's work, in a channel tab.\ncapabilities: 1 bot, 0 static tabs, 1 configurable tab, 0 message extensions, 0 declarative or custom agents; channel-enabled\nvalidDomains: *.tallyboard.example\nwebApplicationInfo: none\nRSC permissions: ChannelMessage.Read.Group (Application)\nsubscriptionOffer: tallyboardinc.tallyboard-teams",
"offer": "The offer as pasted: offer tallyboard-teams; pricing model flat rate; contract standard contract\nPlans (id | name | visibility | pricing | seats | price | dimensions):\n- starter | Starter | public | flat rate | no seat limits | price 99 USD per month | dimensions reports=0.05\n- business | Business | public | flat rate | no seat limits | price 499 USD per month | dimensions reports=0.03, exports=0.10\nThe offer is not published yet.",
"setup": "Landing page: given - https, contains #\nWebhook: given - https\nEntra app multitenant: no\nPermissions requested at activation: User.Read, Group.Read.All\nNotes for certification:\nnone given",
"report": "The validation report:\nTeams app validation report - Tallyboard for Teams 1.0.0\n\nHello,\n\nWe tested your submission and found the following issues:\n\n1. [Must fix] Publisher Verification is not complete for this Partner Center account.\n2. [Must fix] The app name contains \"Teams\". Remove it from the short and full names in the manifest and in Partner Center.\n3. [Blocker] We could not test the app: the notes for certification give no test accounts.\n4. [Must fix] The manifest has no terms of use link.\n\nPlease fix these issues and reply to this email before you resubmit.\n\nValidation team",
"carry": "The listing plan (path transact_private, co-sell not_targeted), the locked-choice register:\n- pricing model: flat rate today, which a Teams link cannot keep, so decide per user for every plan before the first publish (unassigned, before first publish)\n- plan id: starter (Starter) (unassigned, before first publish)\n- plan id: business (Business) (unassigned, before first publish)\n- dimensions: reports = 0.05 on starter, which per-user plans cannot keep (unassigned, before first publish)\n- dimensions: reports = 0.03 on business, which per-user plans cannot keep (unassigned, before first publish)\n- dimensions: exports = 0.10 on business, which per-user plans cannot keep (unassigned, before first publish)\n- plan visibility: starter public; going private later is locked (unassigned, before first publish)\n- plan visibility: business public; going private later is locked (unassigned, before first publish)\n- contract: standard contract (unassigned, before first publish)\nThe plan's fixes:\n- teams_pricing: Set every plan to per user before the first publish, drop the reports and exports dimensions, and set a seat minimum and maximum per plan, because a Teams-linked offer must be per user and metering exists only on flat-rate plans.\n- landing_hash: Serve activation from a landing page path with no # in the URL, keep it always up, and enter that address in Technical configuration.\n-\n\n[... 2286 more characters cut from this display ...]",
"facts": "The page's checks (8 fail, 6 warn, 8 pass, 3 info, 12 n/a); verdict: returned as it stands; rule | id | status | evidence:\n- teams | teams_pricing | fail | the offer is flat rate but the manifest declares subscriptionOffer - the pricing model is fixed per offer, and a Teams link needs per user: a second offer, review and MACC enrollment; per-user plans carry no metered dimensions\n- teams | link_live | warn | subscriptionOffer is declared while the offer is in validation (answer 4) - linking needs a live SaaS offer; add it once live, and that update re-enters validation\n- teams | subscription_offer_id | pass | \"tallyboardinc.tallyboard-teams\" names the offer tallyboard-teams\n- locked | pricing_mixed | pass | every plan is flat rate\n- locked | metering_per_user | pass | metered dimensions only on flat-rate plans; metering is sent hourly† per resource and dimension, and events older than 24 hours† are rejected\n- locked | locked_pricing | na | the offer is not published yet (interview) - locked once it publishes\n- locked | locked_plan_id | na | the offer is not published yet (interview) - locked once it publishes\n- locked | locked_seats | na | the offer is not published yet (interview) - locked once it publishes\n- locked | locked_dimensions | na | the offer is not published yet (interview) - locked once it publishes\n- locked | locked_contract | na | the offer is not published yet\n\n[... 8849 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "fixes",
"summary": "The report returned Publisher Verification, the Teams name, missing test accounts and the terms of use link; the resubmission fixes all four in one pass and re-enters validation.",
"points": [
{
"n": 1,
"report": "Publisher Verification is not complete for this Partner Center account.",
"severity": "must_fix",
"fix": "Complete Publisher Verification for the Partner Center account before resubmitting.",
"check": "publisher_verification"
},
{
"n": 2,
"report": "The app name contains Teams; remove it from the short and full names in the manifest and in Partner Center.",
"severity": "must_fix",
"fix": "Rename the app Tallyboard Scorecards in the manifest's short and full names and use the same name in Partner Center.",
"check": "app_name"
},
{
"n": 3,
"report": "The app could not be tested: the notes for certification give no test accounts.",
"severity": "blocker",
"fix": "Add two test accounts without MFA and the purchase-to-activation steps to the notes for certification.",
"check": "cert_notes"
},
{
"n": 4,
"report": "The manifest has no terms of use link.",
"severity": "must_fix",
"fix": "Add the terms of use link to the developer block of the manifest and match it in Partner Center.",
"check": "developer_urls"
}
],
"resubmit": {
"object": "teams_app",
"effect": "The fixes change the manifest and Partner Center, so this resubmission re-enters validation; a web-service-only change would not.",
"when": "Reply to the report first, wait for confirmation, then resubmit once with all four fixes in one pass."
},
"reply": "Hello,\n\nThank you for the review. We have fixed every item:\n1. Publisher Verification is now complete for our Partner Center account.\n2. The app is renamed Tallyboard Scorecards in the manifest's short and full names and in Partner Center.\n3. The notes for [... 242 more characters cut from this display]",
"footnote": "† verify in Partner Center before quoting it, since Microsoft renames programs and moves criteria yearly.",
"caveats": [
"The report did not raise the flat-rate pricing, but the Teams link still needs per-user plans before the first publish.",
"Take subscriptionOffer out of this resubmission until the offer is live, as the plan says."
]
}
Cut from this display only (the saved reply and the check use the full text): reply (498 characters, 256 shown).
What step 5 prints for it: 1 must_fix publisher_verification | 2 must_fix app_name | 3 blocker cert_notes | 4 must_fix developer_urls
The page's check: kept - nothing to flag
plan - A SaaS on another cloud, already published
The page read it as: returned as it stands; stage live; top rung transact_private; co-sell cosell_ready; register rows pricing model 1, plan id 2, seat limits 2, dimensions 0, contract 1, plan visibility 2; fail checks: locked_pricing, macc_azure, ip_cosell; against the plans as published: pricing model changed (flat rate -> per user).
Input (facts is 9418 characters; the first 1390 are shown):
{
"task": "plan",
"product": "What the product does: Routekeep indexes the bills of lading and delivery notes a logistics team stores and answers questions about them as a Copilot agent. The service runs on another cloud and integrates with Microsoft 365 through the agent and Entra single sign-on. A large customer wants to renew its off-marketplace contract through a private offer.\nMicrosoft 365 surface (answer 1): a Copilot agent\nHosting (answer 2): another cloud; integrates with a Microsoft product: yes\nPricing and buyers (answer 3): offer type transact; buyers hold Azure commitments (MACC): yes\nStage (answer 4): live; offer already published: yes\nTarget date for a first Marketplace transaction (answer 5): not given; a partner manager or sales lead owns Microsoft seller outreach: yes\nPublisher Verification done: yes; Publisher Attestation: due before an update; Frontier Accelerate enrolled: yes\nAzure consumed or billed through the offer in the trailing 12 months† reaches the $100,000† threshold: no; reference architecture diagram ready: no\nCertification test accounts without MFA: yes; regulated buyers' security reviews stall deals: yes; renewal, migrated or upsell private offer planned: yes",
"manifest": "manifest.json, the fields the review reads:\nmanifestVersion 1.19; $schema missing; id given; version 3.0.0\nname.short: Routekeep; name.full: Routekeep shipment documents\ndeveloper: name Routekeep; websiteUrl https; privacyUrl https; termsOfUseUrl https; mpnId not given\ndescription.short: Find any bill of lading or delivery note by asking.\ncapabilities: 0 bots, 0 static tabs, 0 configurable tabs, 0 message extensions, 1 declarative or custom agent\nvalidDomains: routekeep.example\nwebApplicationInfo: id given, resource given\nRSC permissions: none\nsubscriptionOffer: none",
"offer": "The offer as pasted: offer routekeep; pricing model per user; contract standard contract\nPlans (id | name | visibility | pricing | seats | price | dimensions):\n- crew | Crew | public | per user | seats 1-1000 | price 9 USD per user per month | no dimensions\n- fleet | Fleet | private | per user | seats 50-10000 | price 7 USD per user per month | no dimensions\nThe plans as published: offer routekeep; pricing model flat rate; contract standard contract\nPlans (id | name | visibility | pricing | seats | price | dimensions):\n- crew | Crew | public | flat rate | no seat limits | price 900 USD per month | no dimensions\n- fleet | Fleet | private | flat rate | no seat limits | price 6000 USD per month | no dimensions",
"setup": "Landing page: given - https, no #\nWebhook: given - https\nEntra app multitenant: yes\nPermissions requested at activation: User.Read\nNotes for certification:\nTest accounts: two users in our test tenant without MFA; no one from our team needs to be online.\nPurchase to activation: buy the Crew plan, open Set up now, sign in on the landing page with the first test account and the subscription activates in under a minute.",
"report": "",
"carry": "",
"facts": "The page's checks (3 fail, 2 warn, 22 pass, 3 info, 7 n/a); verdict: returned as it stands; rule | id | status | evidence:\n- teams | teams_pricing | na | no Teams link: the manifest has no subscriptionOffer and answer 1 is not a Teams app on a transact offer\n- teams | link_live | na | no subscriptionOffer in the manifest\n- teams | subscription_offer_id | na | no subscriptionOffer in the manifest\n- locked | pricing_mixed | pass | every plan is per user\n- locked | metering_per_user | na | no metered dimension\n- locked | locked_pricing | fail | the offer: flat rate -> per user - locked once published: a new offer or plan\n- locked | locked_plan_id | pass | unchanged against the plans as published\n- locked | locked_seats | pass | unchanged against the plans as published\n- locked | locked_dimensions | pass | unchanged against the plans as published\n- locked | locked_contract | pass | unchanged against the plans as published\n- locked | locked_visibility | pass | unchanged against the plans as published\n- listing | listing_eligibility | warn | another cloud with a Microsoft integration (answer 2) - say how it integrates in the description, accept Entra SSO; it is not purchasable in the Azure management console\n- listing | offer_type | pass | transact (answer 3) - MACC, IP co-sell and private offers stay open\n- saas | landing_hash | pass | no # in the landing page URL\n- saas |\n\n[... 8028 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "plan",
"summary": "The pasted per-user plans contradict the flat-rate offer as published, and MACC buyers cannot draw down on a service on another cloud; the plan keeps the published offer, adds per user as a new offer and stops at co-sell ready.",
"path": {
"rung": "transact_private",
"why": "A transactable SaaS offer with private offers stays first; another cloud deletes IP co-sell, and a Copilot agent follows its own Microsoft 365 submission guide, so the Teams link drops out and the agent alone comes next."
},
"locked": [
{
"choice": "pricing model",
"decision": "per user as pasted, but the offer was published flat rate: per user needs a new offer",
"owner": "unassigned",
"by": "already published"
},
{
"choice": "plan id",
"decision": "crew (Crew)",
"owner": "unassigned",
"by": "already published"
},
{
"choice": "plan id",
"decision": "fleet (Fleet)",
"owner": "unassigned",
"by": "already published"
},
{
"choice": "seat limits",
"decision": "crew: minimum 1, maximum 1000, only on a new per-user offer",
"owner": "unassigned",
"by": "already published"
},
{
"choice": "seat limits",
"decision": "fleet: minimum 50, maximum 10000, only on a new per-user offer",
"owner": "unassigned",
"by": "already published"
}
],
"fixes": [
{
"check": "locked_pricing",
"change": "Leave the published flat-rate plans as they are and create a new offer, priced per user with the crew and fleet seat limits, since a published pricing model needs a new offer or plan."
},
{
"check": "macc_azure",
"change": "Tell the MACC buyers that purchases of this offer do not draw down their Azure commitments, because MACC counts only for usage running in Azure."
},
{
"check": "ip_cosell",
"change": "Drop IP co-sell from the plan while the service runs on another cloud, unless an Azure migration is already decided, and stop at co-sell ready."
},
{
"check": "listing_eligibility",
"change": "Say in the description how Routekeep integrates with Microsoft 365 through the agent and Entra single sign-on, and that it is not purchasable in the Azure management console."
},
{
"check": "attestation",
"change": "Complete Publisher Attestation before submitting the next update of the agent."
},
{
"check": "renewal_attestation",
"change": "Create the renewal private offer for the large customer with the renewal attestation ticked at creation, so the fee halves† for the whole term."
}
],
"submission": [
{
"item": "Publisher Attestation before the next update.",
"object": "teams_app",
"done": false
},
{
"item": "The agent package validated under its own Microsoft 365 submission guide.",
"object": "teams_app",
"done": false
},
{
"item": "A new per-user offer with the crew and fleet plans, next to the published flat-rate offer.",
"object": "saas_offer",
"done": false
},
{
"item": "The renewal private offer for the large customer, with the renewal attestation ticked when it is created.",
"object": "saas_offer",
"done": false
},
{
"item": "The one-pager, pitch deck and a sales contact per geography for co-sell ready.",
"object": "cosell",
"done": false
},
{
"item": "Frontier Accelerate for Marketplace enrollment.",
"object": "cosell",
"done": true
}
],
"cosell": {
"status": "cosell_ready",
"gap": "The one-pager, pitch deck and a sales contact per geography; IP co-sell stays out of reach while the service runs on another cloud."
},
"timeline": [
{
"step": "Complete Publisher Attestation, then submit the agent update.",
"wait": "before the update"
},
{
"step": "Create the new per-user offer and the renewal private offer with the renewal attestation ticked at creation.",
"wait": "engineering weeks, not stated in the input"
},
{
"step": "Submit the co-sell solution with its collateral.",
"wait": "a few weeks of review"
},
{
"step": "Start Microsoft 365 Certification, since regulated buyers' security reviews stall deals.",
"wait": "a yearly audit, duration not stated in the input"
}
],
"footnote": "† verify in Partner Center before quoting it, since Microsoft renames programs and moves criteria yearly.",
"caveats": [
"An offer created before October 2024† never qualifies for the renewal attestation; confirm when the customer's current contract was signed.",
"Confirm with Partner Center support how existing flat-rate customers move to the new per-user offer."
]
}
Cut from this display only (the saved reply and the check use the full text): locked (8 items, 5 shown).
What step 5 prints for it: locked_pricing | macc_azure | ip_cosell | listing_eligibility | attestation | renewal_attestation
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./mskit.js"); require("./msreply.js")), then:
const K = MsKit, R = MsReply;
const f = K.form(Object.assign(K.blank(), { surface: "teams", stage: "building", offertype: "transact",
plans: "offer: notes | pricing: flat | contract: standard\nplan: base | Base | public | price 50 USD per month | dimension: calls=0.01",
landing: "https://app.notes.example/#/start", what: "Notes for every team, in a Teams tab." }));
// f.parsed (error with its line and column), f.m (the manifest), f.offer (the plans), f.register (the locked choices),
// f.rows (the checks), f.locked (against the plans as published), f.ladder, f.cosell, f.timeline, f.plan (route, packet, report items)
// K.registerCsv(f), K.checksCsv(f), K.board(f), K.applyPlan(f, K.board(f), planReply), K.applyPlans(f) - the exports
const b = K.buildInput(f, "plan"); // or "fixes" (needs report; R.laneForm(form, "fixes", planReply) fills carry)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "plan");
// R.check(reply, b) // {status: kept or flagged, notes, fixed}
Get a token for the steps below from /tokens.html.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://appsource-ready.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://appsource-ready.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// appsource-ready.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://appsource-ready.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://appsource-ready.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// AppsourceReady.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 AppsourceReady {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://appsource-ready.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://appsource-ready.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://appsource-ready.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://appsource-ready.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"appsource-ready"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "appsource-ready"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "appsource-ready" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "appsource-ready"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "appsource-ready"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "appsource-ready" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "appsource-ready"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "appsource-ready" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "plan", "product": "What the product does: Tallyboard turns a team's tasks and channel activity into a weekly scorecard tab inside Teams and posts a Friday summary to the channel. Teams buy it in the Teams store; scorecard reports and exports are metered.
...", "manifest": "manifest.json, the fields the review reads:
...", "offer": "The offer as pasted: offer tallyboard-teams; pricing model flat rate; contract standard contract
...", "setup": "Landing page: given - https, contains #
...", "report": "", "carry": "", "facts": "The page's checks (...); verdict: returned as it stands; rule | id | status | evidence:
- teams | teams_pricing | fail | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "plan", "product": "What the product does: Tallyboard turns a team's tasks and channel activity into a weekly scorecard tab inside Teams and posts a Friday summary to the channel. Teams buy it in the Teams store; scorecard reports and exports are metered.
...", "manifest": "manifest.json, the fields the review reads:
...", "offer": "The offer as pasted: offer tallyboard-teams; pricing model flat rate; contract standard contract
...", "setup": "Landing page: given - https, contains #
...", "report": "", "carry": "", "facts": "The page's checks (...); verdict: returned as it stands; rule | id | status | evidence:
- teams | teams_pricing | fail | ..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "plan" or "fixes", product, manifest, offer, setup, report, 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 appsource-ready:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A plan reply carries summary, path, locked, fixes, submission, cosell, timeline, footnote and caveats; a fixes-lane reply carries summary, points, resubmit, reply, footnote and caveats. The code below prints the gist of either: each fix of the plan, or each report point with its severity and fix.
KEY="appsource-ready:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "fixes" then (.points[] | "\(.n). [\(.severity)] \(.report) -> \(.fix)")
else (.fixes[] | "\(.check) \(.change)") end' reply.json
import hashlib, time
key = "appsource-ready:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "fixes":
for x in reply["points"]:
print(x["n"], x["severity"], x["report"], "->", x["fix"])
print(reply["resubmit"]["object"])
else:
for x in reply["fixes"]:
print(x["check"], x["change"])
import { createHash } from "node:crypto";
const key = `appsource-ready:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "fixes") { for (const x of reply.points) console.log(x.n, x.severity, x.report, "->", x.fix); console.log(reply.resubmit.object); }
else { for (const x of reply.fixes) console.log(x.check, x.change); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "appsource-ready:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
key, name, field := "fixes", "check", "change"
if reply["task"] == "fixes" {
key, name, field = "points", "report", "fix"
}
ls, _ := reply[key].([]any)
for _, x := range ls {
xx := x.(map[string]any)
fmt.Println(xx[name], xx[field])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "appsource-ready:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("fixes")) {
for (JsonNode x : reply.path("points")) System.out.println(x.path("report").asText() + " -> " + x.path("fix").asText());
} else {
for (JsonNode x : reply.path("fixes")) System.out.println(x.path("check").asText() + " " + x.path("change").asText());
}
require "digest"
key = "appsource-ready:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "fixes"
reply["points"].each { |x| puts "#{x["report"]} -> #{x["fix"]}" }
else
reply["fixes"].each { |x| puts "#{x["check"]} #{x["change"]}" }
end
$key = "appsource-ready:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "fixes") { foreach ($reply["points"] as $x) echo $x["report"], " -> ", $x["fix"], "\n"; }
else { foreach ($reply["fixes"] as $x) echo $x["check"], " ", $x["change"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"appsource-ready:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "fixes") { foreach (var x in reply["points"]!.AsArray()) Console.WriteLine($"{x!["report"]} -> {x["fix"]}"); }
else { foreach (var x in reply["fixes"]!.AsArray()) Console.WriteLine($"{x!["check"]} {x["change"]}"); }
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: appsource-ready:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "plan", "product": "What the product does: Tallyboard turns a team's tasks and channel activity into a weekly scorecard tab inside Teams and posts a Friday summary to the channel. Teams buy it in the Teams store; scorecard reports and exports are metered.
...", "manifest": "manifest.json, the fields the review reads:
...", "offer": "The offer as pasted: offer tallyboard-teams; pricing model flat rate; contract standard contract
...", "setup": "Landing page: given - https, contains #
...", "report": "", "carry": "", "facts": "The page's checks (...); verdict: returned as it stands; rule | id | status | evidence:
- teams | teams_pricing | 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": "appsource-ready:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `appsource-ready:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "appsource-ready:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "appsource-ready:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "appsource-ready:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: appsource-ready:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"appsource-ready:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);