Run Forge Ready from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page parses the manifest, classes every scope by its syntax, lists the egress, the remotes and the auth providers, runs every check, diffs the manifest against the previous version's, ranks the architecture and trust ladders, resolves the payment model and counts the review rounds from today, then hands it all to the run as facts. The run plans the submission or answers a returned ticket; the page then holds it to its own facts (fgreply.js). Forge Ready is not affiliated with or endorsed by Atlassian.
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":"forge-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, app, manifest, features, facts) is missing. The page always sends all eight of task, app, manifest, previous, features, ticket, carry, facts; ticket and carry are empty strings for plan, and previous is empty when no previous 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 submission, then a returned ticket:
plan- the submission planned: the page's architecture and payment model, exactly one row per manifest scope with the feature that calls it (keep or drop; "needs a feature note" when the input gives none), one row per declared egress address with its inScopeEUD flag, the irreversibles register (framework, payment model, licensing, scopes, egress, app name - each with an owner), one fix per failing check, and the timeline with a dagger on every Atlassian figure.fixes- a returned ticket answered: one fix per point the ticket raises, in its order, whether the resubmission is a major (the page's diff decides when both manifests are pasted) and its re-review and admin-consent effect, then the reply to the reviewer - every point numbered, every fix in one pass, never arguing, never asking to expedite. Needs the ticket text inticket.
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 decision, scope table, egress and fixes 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". |
app | yes | the app name as listed, the products, the stage, the payment model, the interview answers and the launch date with its days from today; "not given" means not given. |
manifest | yes | the manifest.yml as the page read it, field by field (app id, runtime, licensing, Connect key, Forge and Connect modules, scopes, egress with inScopeEUD, remotes, auth providers). |
previous | send it (empty when no previous manifest) | the previous version's manifest.yml read the same way, or empty when none was pasted. |
features | yes | what the app does, then the feature notes, one per scope; "none given" means none. |
ticket | send it (the returned ticket for fixes, empty for plan) | fixes task only: the returned ticket text as pasted. |
carry | send it (empty for plan) | fixes task only: the submission plan's decision, scope table, egress and fixes 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 scope table, the egress list, the major-version diff, the architecture ladder, the payment resolution, the trust ladder, the timeline, 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/atlassian-marketplace-playbook - the only standard the run applies:
- Scope: a vendor's Jira, Confluence or JSM app going through Atlassian Marketplace approval, or a listed app shipping a new version. Not listing copy or search rank, not Forge code, not internal-only apps, not Bitbucket, Compass or Trello apps, not buyers vetting apps.
- Rule 1, Forge only: the Marketplace accepts no new Connect apps, Connect-descriptor apps receive no updates, and Connect support ends on 31 Jan 2027†. Existing Connect vendors move to Forge on the same listing (app.connect.key in manifest.yml).
- Rule 2, Free or Paid via Atlassian (PvA): Forge cloud apps get no Paid via Vendor option without Atlassian's written permission.
- Rule 3, payment-model changes are new versions, not toggles: Free to PvA needs licensing enabled in the manifest, a new version, re-approval, and a major version each site admin must accept; existing installs count as unlicensed until the admin subscribes.
- Rule 4, a PvA license matches the host site's licensed user count; customers cannot license a subset of users.
- Rule 5, every added scope, egress domain, remote or auth provider creates a major version. Each major re-enters Marketplace review (questionnaire, scan, 5-10 business days†), and sites needing new permissions stay on the old major until their admin consents. Removing egress is minor; removing a scope is still major.
- Rule 6, no collected Atlassian API tokens: use OAuth 2.0 or Forge auth; apps storing user API tokens are refused unless Atlassian grants an exemption.
- Architecture, ranked by efficiency for a vendor connecting its own SaaS: Forge UI with Forge remotes to your backend (in-product UI, OAuth, your existing backend) > an OAuth 2.0 (3LO) integration listed as not directly installable (cheapest when the integration already runs on Atlassian OAuth with no UI inside Jira; no in-product surface, no path to AEC). Runs on Atlassian is out of reach for a connector: it requires no egress except analytics. For buyers blocking on egress, offer data residency for remote data, disclosed egress and AEC. Declare every first-hop egress domain and its inScopeEUD flag correctly in the first version, with no broad wildcards. Forge consumption above each monthly free allowance is invoiced to the developer, free apps included.
- Payment, resolved this way: a connector to your SaaS is Free, with some in-product value that works without an account and the paid account stated in the listing; revenue from the Atlassian app itself is PvA from version one, licensing enabled, the license checked in code; an app already listed Free keeps existing features free for a stated grace period and gates only new features; any external plan touching Jira features needs Atlassian's ruling written on the ticket before building billing. PvA prices per host user count, the annual price at 10 times monthly†; existing customers keep the old price for 60 days† after a change. Partner share: 83%† for Forge-only apps (no Connect module or Connect scope), 75%† otherwise, and 100% until $1M lifetime Forge revenue†.
- Submission: clear the prerequisites first - ECOHELP access for the submitter, partner identity verification (KYC/KYB), the app security questionnaire, the Privacy & Security tab, the payment profile, a privacy policy, End User Terms, a DPA if you process personal data, documentation. Name the app "Name for Jira", never "Jira Name". Enable sharing, submit, and put test credentials for your SaaS on the ticket. Plan three weeks per round† (official figures range from one week to 15 business days†) plus one returned round. The listing is locked in review. A returned ticket: fix every point in one pass.
- Connect migration: front-load the first Forge version with every Forge scope, remote and fetch permission the end state needs; never change the Connect baseUrl, an auth provider, or free vs paid in that first version; remove every Connect module and Connect scope to reach the Forge share and AEC.
- Versions and consent: batch privilege-escalating changes into planned majors with release notes telling admins why; keep the previous major working and backport security fixes as minors.
- Keep the listing: cloud Critical findings within 10 days†, High within 4 weeks† (a missed High hides an unbadged app on day one, or strips badges and hides it after 15 days†); silence for 90 days† hides the app; report a security incident within 24 hours†; ship at least one version every 18 months†.
- Trust ladder, by value per effort: the Privacy & Security tab with a trust-center link > a public Marketplace bug bounty > Atlassian Enterprise Certified (AEC). Promote AEC when enterprise deals stall on vendor security review and an audit is already under way.
- Mark every Atlassian figure you quote with † right after it, and close with the footnote: "† verify against current Atlassian developer docs, because Atlassian changed its framework, badge and revenue-share rules within one year."
House rules
- Use only the input. Never invent a scope, a domain, a module, a date, a price, a share, a rate 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 architecture at the top of the ladder, the payment resolution and the major-version verdict. Never move them; explain them.
- Every Atlassian figure carries † right after it, and the
footnotefield is exactly the skill's footnote. - Never imply that Atlassian certifies, endorses, approves or recommends the app, and never write a phrase that could be read that way.
- No URL: never write a scheme such as https. Write egress domains exactly as the input gives them, without a scheme, and only domains the input contains. Write "your privacy policy" or "your DPA" where a link belongs.
- Write in the language of the input; keep the JSON keys, check ids, scope names, domains 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 submission: the decision, one justification per scope, the egress, the irreversibles register, one fix per failing check and the timeline.
summary- one sentence, at most 40 words: what stands between the app and approval today and what the plan changes.decision- an object:architecture(exactly the facts' top rung:forge_remotesoroauth_3lo),payment(exactly the facts' resolution:freeorpva),why(one or two sentences naming the interview answer that drove each).scopes- exactly one object per scope the manifest requests, in the facts' scope-table order:scope(spelled exactly as in the manifest),justification(one sentence: the feature that calls the scope and what it does with the data, from the feature notes; write exactly "needs a feature note" when the scope has no note; never a feature the input does not have, never the scope's definition, never the same sentence for two scopes),action(keepordrop). No other scope.egress- exactly one object per declared egress address, in the facts' order:address(exactly as declared),inScopeEUD(trueorfalseas declared, or "declare it" when the facts say it is not declared),why(one sentence: the data sent to that host and why, from the input, or "not stated in the input"). No other address.[]when the manifest declares none.irreversibles- the register, one object for each offramework,payment model,licensing,scopes,egressandapp name:item(that word),decision(one sentence),owner(a role, not a name),by(a date on or before the launch only when the input gives a launch date; otherwise exactly "before submission").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 listing, the packet or the code). Every fail check appears.timeline- the steps from here to listed, each an object:step(one sentence),wait(the wait, with † on every Atlassian figure).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",
"decision": { "architecture": "forge_remotes", "payment": "free", "why": "one or two sentences" },
"scopes": [ { "scope": "read:jira-work", "justification": "one sentence", "action": "keep" } ],
"egress": [ { "address": "api.vendor.example", "inScopeEUD": "true", "why": "one sentence" } ],
"irreversibles": [ { "item": "framework", "decision": "one sentence", "owner": "Engineering lead", "by": "before submission" } ],
"fixes": [ { "check": "egress_wildcard", "change": "one sentence" } ],
"timeline": [ { "step": "one sentence", "wait": "three weeks per round†" } ],
"footnote": "† verify against current Atlassian developer docs, because Atlassian changed its framework, badge and revenue-share rules within one year.",
"caveats": [ ]
}
The page holds it to its own facts (check() in fgreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL with a scheme; no backticks; no implied Atlassian endorsement; a summary of at most 40 words; every Atlassian figure with its dagger and the exact footnote; the page's architecture and payment model (a different one is put back and flagged); exactly one scope row per manifest scope, none invented, an action of keep or drop, a justification on each, exactly "needs a feature note" for a scope with no note and never one justification for two scopes; exactly one egress row per declared address, none invented, with the declared inScopeEUD or "declare it"; all six irreversibles, each with an owner and a by that is "before submission" or a date on or before the launch; a fix for every fail check and only the page's check ids; a timeline.
fixes
Answer the returned ticket: one fix per point the ticket raises, whether the resubmission is a major and what that does, then the reply to the reviewer.
summary- one sentence, at most 40 words: what the ticket returned and what the resubmission changes.points- one object per distinct point the ticket raises, in its order:n(1, 2, 3...),ticket(a short quote or paraphrase of the point, from the ticket),fix(the concrete change, one sentence),check(the page's check id the point matches, or ""). When the carried plan has a fix or a scope row for that check, use it.resubmit- an object:major(true or false: exactly the facts' diff verdict when the previous manifest was pasted; otherwise true when a fix adds a scope, an egress domain, a remote or an auth provider, or enables licensing),effect(one sentence: the re-review and admin-consent effect - a major re-enters Marketplace review, 5-10 business days†, and sites stay on the old major until their admin consents),when(one sentence: one resubmission once every point is fixed, in one pass).reply- the reply to the reviewer on the ticket: thanks them, lists every point as a numbered line with its fix, says every fix is in this one resubmission; 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, "ticket": "short quote or paraphrase", "fix": "one sentence", "check": "app_name" } ],
"resubmit": { "major": true, "effect": "one sentence", "when": "one sentence" },
"reply": "the reply to the reviewer, one numbered line per point",
"footnote": "† verify against current Atlassian developer docs, because Atlassian changed its framework, badge and revenue-share rules within one year.",
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; the same text rules; at least as many points as the ticket numbers, only the page's check ids, a fix on each; major true or false, equal to the page's diff when both manifests were pasted (a different one is put back and flagged), an effect that names admin consent for a major, and a when; a reply that lists every point as a numbered line, never asks to expedite and never argues.
Worked examples
Three runs from the page's free samples (invented apps, hosts on the reserved .example domain): the submission plan for a Connect app moving to Forge, the fixes for its returned ticket carrying that plan, and the plan for a paid Jira app launching before approval can land. 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 Connect app moving to Forge
The page read it as: returned as it stands; stage Forge app returned; architecture forge_remotes; payment pva; scopes read:connect-jira (connect), write:connect-jira (connect), read:jira-work (classic), write:jira-work (classic), read:jira-user (classic), manage:jira-configuration (admin); egress *; fail checks: first_forge_version, payment_vendor, revenue_licensing, api_tokens, egress_wildcard, app_name, kyc, questionnaire, end_user_terms, test_credentials; against the previous manifest: a major.
Input (facts is 9232 characters; the first 1392 are shown):
{
"task": "plan",
"app": "App name as listed: Jira Timesheets Pro; products: Jira\nStage: Forge app returned\nPayment model today: paid via vendor; revenue from the Atlassian app itself: yes; works without an account on your SaaS: no\nCollects users' Atlassian API tokens: yes\nBuyers require data residency: no; buyers require no data egress: no; buyers run a vendor security review: no\nPublished: privacy policy yes, End User Terms no, DPA no\nPrerequisites: submitter can open ECOHELP tickets yes; KYC/KYB done no; security questionnaire done no; test credentials for your SaaS on the ticket no\nLaunch date: not given",
"manifest": "manifest.yml (YAML), the fields the review reads:\napp.id: ari:cloud:ecosystem::app/7d3c9a10-5e2b-4c61-9f0e-timesheets; runtime: nodejs22.x; licensing: not enabled\napp.connect.key: com.timesheets.example.jira; connect remote connect; authentication jwt\nForge modules: jira:issuePanel (1), jira:projectPage (1), function (1)\nConnect modules: connectModules.jira:generalPages, connectModules.jira:jiraIssueTabPanels\nscopes: read:connect-jira, write:connect-jira, read:jira-work, write:jira-work, read:jira-user, manage:jira-configuration\negress (fetch): * (backend, inScopeEUD not declared, wildcard)\nremotes: connect api.timesheets.example\nauth providers: none",
"previous": "The previous version's manifest.yml (YAML), the fields the review reads:\napp.id: ari:cloud:ecosystem::app/7d3c9a10-5e2b-4c61-9f0e-timesheets; runtime: not set; licensing: not enabled\napp.connect.key: com.timesheets.example.jira; connect remote connect; authentication jwt\nForge modules: none\nConnect modules: connectModules.jira:generalPages, connectModules.jira:jiraIssueTabPanels\nscopes: read:connect-jira, write:connect-jira\negress (fetch): none\nremotes: connect timesheets.example\nauth providers: none",
"features": "What the app does:\nTimesheets Pro adds a timer panel to each Jira issue that logs time to the issue, and a project page with a weekly timesheet report per person. Each user pastes a personal Atlassian API token in the app's settings so the report can read worklogs across projects. Teams buy seats on timesheets.example.\n\nFeature notes, one per scope (scope | what the feature does with it):\nread:jira-work | reads issues and worklogs to build the weekly timesheet report\nwrite:jira-work | logs time to the issue when a person stops the panel's timer\nread:jira-user | shows each person's display name in the report",
"ticket": "",
"carry": "",
"facts": "The page's checks (10 fail, 5 warn, 3 pass, 0 info, 7 n/a); verdict: returned as it stands; rule | id | status | evidence:\n- 1 | connect_only | pass | Forge modules (jira:issuePanel, jira:projectPage, function) beside the Connect pieces - see connect_leftovers\n- connect | connect_leftovers | warn | Connect modules: connectModules.jira:generalPages, connectModules.jira:jiraIssueTabPanels; Connect scopes: read:connect-jira, write:connect-jira - the partner share is 75%† instead of 83%† for Forge-only apps, and no path to AEC; removing Connect scopes and the Connect remote needs forge version bulk-upgrade\n- connect | first_forge_version | fail | the Connect baseUrl moved from timesheets.example to api.timesheets.example - never change the Connect baseUrl, an auth provider, or free vs paid in the first Forge version\n- 2 | payment_vendor | fail | paid via vendor (interview) - Forge cloud apps get no Paid via Vendor option without Atlassian's written permission; the page resolves it to Paid via Atlassian (PvA): revenue from the Atlassian app itself: PvA from version one, licensing enabled, the license checked in code (context.license.active, filter.appIsLicensed on triggers); Forge apps get no Paid via Vendor option without Atlassian's written permission, and any external plan touching Jira features needs Atlassian's ruling written on the ticket before you build billing\n- 2 |\n\n[... 7840 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "plan",
"summary": "A changed Connect baseUrl, a wildcard egress, collected API tokens, a vendor-billed plan with licensing off, a Jira-first name and four open prerequisites block approval; the plan fixes all of them in one resubmission.",
"decision": {
"architecture": "forge_remotes",
"payment": "pva",
"why": "The app already has an issue panel and a project page inside Jira, so Forge UI with the existing backend as a remote stays first. You want revenue from the Jira app itself, so it is Paid via Atlassian from this version, not billed on your own site."
},
"scopes": [
{
"scope": "read:connect-jira",
"justification": "needs a feature note",
"action": "keep"
},
{
"scope": "write:connect-jira",
"justification": "needs a feature note",
"action": "keep"
},
{
"scope": "read:jira-work",
"justification": "Reads issues and worklogs to build the weekly timesheet report on the project page.",
"action": "keep"
},
{
"scope": "write:jira-work",
"justification": "Logs time to the issue when a person stops the timer in the issue panel.",
"action": "keep"
},
{
"scope": "read:jira-user",
"justification": "Shows each person's display name next to their hours in the report.",
"action": "keep"
},
{
"scope": "manage:jira-configuration",
"justification": "needs a feature note",
"action": "drop"
}
],
"egress": [
{
"address": "*",
"inScopeEUD": "declare it",
"why": "The report reads worklogs through your own service at api.timesheets.example; the wildcard hides that single host from reviewers and admins."
}
],
"irreversibles": [
{
"item": "framework",
"decision": "Ship this version as Forge on the existing listing, keeping app.connect.key and the Connect baseUrl exactly as the previous version had them.",
"owner": "Engineering lead",
"by": "before submission"
},
{
"item": "payment model",
"decision": "Paid via Atlassian from this version; the plan sold on your site stops covering the Jira features.",
"owner": "Head of product",
"by": "before submission"
},
{
"item": "licensing",
"decision": "Set app.licensing.enabled to true and gate the report and the timer on context.license.active.",
"owner": "Engineering lead",
"by": "before submission"
},
{
"item": "scopes",
"decision": "Keep the three classic scopes the panel and the report use, drop manage:jira-configuration, and plan the removal of the two Connect scopes.",
"owner": "Engineering lead",
"by": "before submission"
},
{
"item": "egress",
"decision": "Replace the wildcard with api.timesheets.example and declare its inScopeEUD flag in this version.",
"owner": "Security lead",
"by": "before submission"
},
{
"item": "app name",
"decision": "Rename the listing to Timesheets Pro for Jira.",
"owner": "Marketing lead",
"by": "before submission"
}
],
"fixes": [
{
"check": "first_forge_version",
"change": "Put the Connect remote back on timesheets.example for this first Forge version and move the backend in a later major."
},
{
"check": "payment_vendor",
"change": "Switch the listing to Paid via Atlassian and stop selling Jira seats on your site, unless Atlassian grants written permission on the ticket."
},
{
"check": "revenue_licensing",
"change": "Set app.licensing.enabled to true and check context.license.active in the resolver before the report or the timer runs."
},
{
"check": "api_tokens",
"change": "Remove the API-token field from settings and read worklogs through Forge with the read:jira-work scope the app already requests."
},
{
"check": "egress_wildcard",
"change": "Replace the wildcard egress with api.timesheets.example, the one host the report calls."
}
],
"timeline": [
{
"step": "Clear KYC, the security questionnaire and the End User Terms, then fix every ticket point in one pass.",
"wait": "before resubmitting"
},
{
"step": "Resubmit; the review round runs again.",
"wait": "three weeks per round† (official figures range from one week to 15 business days†)"
},
{
"step": "Budget one more returned round.",
"wait": "three weeks†"
},
{
"step": "Once listed, ship the Connect clean-up as a planned major with release notes for admins.",
"wait": "5-10 business days† of review, then admin consent"
}
],
"footnote": "† verify against current Atlassian developer docs, because Atlassian changed its framework, badge and revenue-share rules within one year.",
"caveats": [
"Confirm that api.timesheets.example is the only host the report calls before declaring it.",
"Confirm whether the worklog sync sends end-user data, to set inScopeEUD correctly.",
"Moving existing customers from your site's plan to Paid via Atlassian needs a stated grace period."
]
}
Cut from this display only (the saved reply and the check use the full text): fixes (12 items, 5 shown).
What step 5 prints for it: first_forge_version | payment_vendor | revenue_licensing | api_tokens | egress_wildcard | app_name | kyc | questionnaire | end_user_terms | test_credentials | broad_scopes | connect_leftovers
The page's check: kept - nothing to flag
fixes - A Connect app moving to Forge
The input carries the saved plan (R.laneForm fills carry from the plan reply), as the page's handoff does, and the returned ticket, which numbers 4 points; against the previous manifest the version is a major.
Input (carry is 2136 characters; the first 1396 are shown; facts is 8642 characters; the first 1392 are shown):
{
"task": "fixes",
"app": "App name as listed: Jira Timesheets Pro; products: Jira\nStage: Forge app returned\nPayment model today: paid via vendor; revenue from the Atlassian app itself: yes; works without an account on your SaaS: no\nCollects users' Atlassian API tokens: yes\nBuyers require data residency: no; buyers require no data egress: no; buyers run a vendor security review: no\nPublished: privacy policy yes, End User Terms no, DPA no\nPrerequisites: submitter can open ECOHELP tickets yes; KYC/KYB done no; security questionnaire done no; test credentials for your SaaS on the ticket no\nLaunch date: not given",
"manifest": "manifest.yml (YAML), the fields the review reads:\napp.id: ari:cloud:ecosystem::app/7d3c9a10-5e2b-4c61-9f0e-timesheets; runtime: nodejs22.x; licensing: not enabled\napp.connect.key: com.timesheets.example.jira; connect remote connect; authentication jwt\nForge modules: jira:issuePanel (1), jira:projectPage (1), function (1)\nConnect modules: connectModules.jira:generalPages, connectModules.jira:jiraIssueTabPanels\nscopes: read:connect-jira, write:connect-jira, read:jira-work, write:jira-work, read:jira-user, manage:jira-configuration\negress (fetch): * (backend, inScopeEUD not declared, wildcard)\nremotes: connect api.timesheets.example\nauth providers: none",
"previous": "The previous version's manifest.yml (YAML), the fields the review reads:\napp.id: ari:cloud:ecosystem::app/7d3c9a10-5e2b-4c61-9f0e-timesheets; runtime: not set; licensing: not enabled\napp.connect.key: com.timesheets.example.jira; connect remote connect; authentication jwt\nForge modules: none\nConnect modules: connectModules.jira:generalPages, connectModules.jira:jiraIssueTabPanels\nscopes: read:connect-jira, write:connect-jira\negress (fetch): none\nremotes: connect timesheets.example\nauth providers: none",
"features": "What the app does:\nTimesheets Pro adds a timer panel to each Jira issue that logs time to the issue, and a project page with a weekly timesheet report per person. Each user pastes a personal Atlassian API token in the app's settings so the report can read worklogs across projects. Teams buy seats on timesheets.example.\n\nFeature notes, one per scope (scope | what the feature does with it):\nread:jira-work | reads issues and worklogs to build the weekly timesheet report\nwrite:jira-work | logs time to the issue when a person stops the panel's timer\nread:jira-user | shows each person's display name in the report",
"ticket": "The returned ticket:\nECOHELP-48213 - Jira Timesheets Pro 2.0.0 (Forge) - Returned\n\nHello,\n\nThank you for submitting Jira Timesheets Pro. We are returning this version for the following reasons:\n\n1. The app name begins with \"Jira\". Apps on the Marketplace must be named \"<Name> for Jira\".\n2. The manifest declares a wildcard egress permission (*). Please list each domain the app connects to.\n3. The app asks users to paste an Atlassian API token in its settings. Apps may not collect user API tokens; please use OAuth 2.0 or Forge authentication.\n4. We could not complete functional testing: please add test credentials for your service to this ticket.\n\nPlease resolve all points and resubmit.\n\nAtlassian Marketplace review",
"carry": "The submission plan (architecture forge_remotes, payment pva), the scope table:\n- read:connect-jira: keep - needs a feature note\n- write:connect-jira: keep - needs a feature note\n- read:jira-work: keep - Reads issues and worklogs to build the weekly timesheet report on the project page.\n- write:jira-work: keep - Logs time to the issue when a person stops the timer in the issue panel.\n- read:jira-user: keep - Shows each person's display name next to their hours in the report.\n- manage:jira-configuration: drop - needs a feature note\nThe egress:\n- * (inScopeEUD declare it): The report reads worklogs through your own service at api.timesheets.example; the wildcard hides that single host from reviewers and admins.\nThe plan's fixes:\n- first_forge_version: Put the Connect remote back on timesheets.example for this first Forge version and move the backend in a later major.\n- payment_vendor: Switch the listing to Paid via Atlassian and stop selling Jira seats on your site, unless Atlassian grants written permission on the ticket.\n- revenue_licensing: Set app.licensing.enabled to true and check context.license.active in the resolver before the report or the timer runs.\n- api_tokens: Remove the API-token field from settings and read worklogs through Forge with the read:jira-work scope the app already requests.\n- egress_wildcard: Replace the wildcard egress with api.timesheets.example,\n\n[... 740 more characters cut from this display ...]",
"facts": "The page's checks (10 fail, 5 warn, 3 pass, 0 info, 7 n/a); verdict: returned as it stands; rule | id | status | evidence:\n- 1 | connect_only | pass | Forge modules (jira:issuePanel, jira:projectPage, function) beside the Connect pieces - see connect_leftovers\n- connect | connect_leftovers | warn | Connect modules: connectModules.jira:generalPages, connectModules.jira:jiraIssueTabPanels; Connect scopes: read:connect-jira, write:connect-jira - the partner share is 75%† instead of 83%† for Forge-only apps, and no path to AEC; removing Connect scopes and the Connect remote needs forge version bulk-upgrade\n- connect | first_forge_version | fail | the Connect baseUrl moved from timesheets.example to api.timesheets.example - never change the Connect baseUrl, an auth provider, or free vs paid in the first Forge version\n- 2 | payment_vendor | fail | paid via vendor (interview) - Forge cloud apps get no Paid via Vendor option without Atlassian's written permission; the page resolves it to Paid via Atlassian (PvA): revenue from the Atlassian app itself: PvA from version one, licensing enabled, the license checked in code (context.license.active, filter.appIsLicensed on triggers); Forge apps get no Paid via Vendor option without Atlassian's written permission, and any external plan touching Jira features needs Atlassian's ruling written on the ticket before you build billing\n- 2 |\n\n[... 7250 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "fixes",
"summary": "The reviewer returned the Jira-first name, the wildcard egress, the collected API tokens and missing test credentials; the resubmission fixes all four in one pass and ships as a major.",
"points": [
{
"n": 1,
"ticket": "The app name begins with Jira; it must be named Name for Jira.",
"fix": "Rename the app Timesheets Pro for Jira in the listing and the module titles.",
"check": "app_name"
},
{
"n": 2,
"ticket": "The manifest declares a wildcard egress permission; list each domain.",
"fix": "Replace the wildcard with api.timesheets.example, the one host the report calls, and declare its inScopeEUD flag.",
"check": "egress_wildcard"
},
{
"n": 3,
"ticket": "The app asks users to paste an Atlassian API token in its settings.",
"fix": "Remove the API-token field and read worklogs through Forge with the read:jira-work scope the app already requests.",
"check": "api_tokens"
},
{
"n": 4,
"ticket": "Testing could not be completed without test credentials for the service.",
"fix": "Add a test account with sample worklogs for your service to the ticket.",
"check": "test_credentials"
}
],
"resubmit": {
"major": true,
"effect": "This version adds scopes and egress, so it re-enters Marketplace review (5-10 business days†) and sites stay on the old major until their admin consents.",
"when": "Resubmit once, after all four points are fixed together in one pass."
},
"reply": "Hello,\n\nThank you for the review. We fixed every point in this one resubmission:\n\n1. The app is now named Timesheets Pro for Jira in the listing and the module titles.\n2. The wildcard egress is replaced by api.timesheets.example, the only host the report [... 302 more characters cut from this display]",
"footnote": "† verify against current Atlassian developer docs, because Atlassian changed its framework, badge and revenue-share rules within one year.",
"caveats": [
"The ticket did not raise the moved Connect baseUrl; put it back on timesheets.example before resubmitting, as the plan says.",
"Finish KYC, the security questionnaire and the End User Terms first, or the review can time out."
]
}
Cut from this display only (the saved reply and the check use the full text): reply (556 characters, 254 shown).
What step 5 prints for it: 1 app_name | 2 egress_wildcard | 3 api_tokens | 4 test_credentials
The page's check: kept - nothing to flag
plan - A paid Jira app launching in 20 days
The page read it as: returned as it stands; stage Forge app returned; architecture forge_remotes; payment pva; scopes read:sprint:jira-software (granular), read:board-scope:jira-software (granular), read:issue-details:jira (granular), storage:app (app); egress forecast.sprintlens.example; fail checks: revenue_licensing; against the previous manifest: no previous manifest.
Input (facts is 6255 characters; the first 1397 are shown):
{
"task": "plan",
"app": "App name as listed: Sprint Lens for Jira; products: Jira\nStage: Forge app returned\nPayment model today: paid via Atlassian; revenue from the Atlassian app itself: yes; works without an account on your SaaS: yes\nCollects users' Atlassian API tokens: no\nBuyers require data residency: no; buyers require no data egress: yes; buyers run a vendor security review: yes\nPublished: privacy policy yes, End User Terms yes, DPA no\nPrerequisites: submitter can open ECOHELP tickets yes; KYC/KYB done yes; security questionnaire done yes; test credentials for your SaaS on the ticket yes\nLaunch date: 2026-11-21 (20 days from today, 2026-11-01)",
"manifest": "manifest.yml (YAML), the fields the review reads:\napp.id: ari:cloud:ecosystem::app/91c0d7e2-sprint-lens; runtime: nodejs22.x; licensing: not enabled\napp.connect.key: none\nForge modules: jira:projectPage (1), jira:dashboardGadget (1), trigger (1), function (2)\nConnect modules: none\nscopes: read:sprint:jira-software, read:board-scope:jira-software, read:issue-details:jira, storage:app\negress (fetch): forecast.sprintlens.example (backend, inScopeEUD false)\nremotes: none\nauth providers: none",
"previous": "",
"features": "What the app does:\nSprint Lens adds a project page with burn-up, scope change and carry-over for every sprint, and a dashboard gadget with sprint health. When a sprint closes, it sends anonymised sprint totals to its forecasting service and stores the forecast in the app's storage. We sell Sprint Lens per user through the Marketplace.\n\nFeature notes, one per scope (scope | what the feature does with it):\nread:sprint:jira-software | reads sprint dates and state for the burn-up chart\nread:board-scope:jira-software | lists the boards a project page can chart\nread:issue-details:jira | reads issue estimates and status to count scope change and carry-over\nstorage:app | keeps each sprint's forecast between page loads",
"ticket": "",
"carry": "",
"facts": "The page's checks (1 fail, 2 warn, 15 pass, 1 info, 6 n/a); verdict: returned as it stands; rule | id | status | evidence:\n- 1 | connect_only | pass | no Connect module and no app.connect.key - 4 Forge module types\n- connect | connect_leftovers | pass | no Connect module and no Connect scope - the Forge share applies: 83%†\n- connect | first_forge_version | na | no previous manifest pasted\n- 2 | payment_vendor | pass | paid via Atlassian (interview)\n- 2 | free_value | na | the page resolves the app to Paid via Atlassian\n- 3 | revenue_licensing | fail | revenue from the app itself (interview) but app.licensing.enabled is not true - PvA from version one, licensing enabled; turning it on later is a new version, a re-approval and a major each site admin must accept, and existing installs count as unlicensed until the admin subscribes\n- 3 | licensing_free | na | licensing is not enabled\n- 6 | api_tokens | pass | no collected API tokens (interview)\n- scopes | scope_features | pass | a feature note for each of 4 scopes\n- scopes | broad_scopes | pass | no manage:* scope, and every write scope's note says what it writes\n- egress | egress_wildcard | pass | 1 egress domain, none a wildcard\n- egress | egress_eud | pass | inScopeEUD declared on every egress domain\n- 5 | major_version | na | no previous manifest pasted - paste it to list the major-version changes\n- name | app_name | pass |\n\n[... 4858 more characters cut from this display ...]"
}
Reply:
{
"task": "plan",
"summary": "Licensing is off on a Paid via Atlassian app, buyers refuse egress and run security reviews, and approval lands after the launch; the plan enables licensing, discloses the one host and moves AEC up.",
"decision": {
"architecture": "forge_remotes",
"payment": "pva",
"why": "The project page and the dashboard gadget live inside Jira, so Forge UI stays first, and buyers' security reviews rule out a 3LO listing with no path to AEC. You sell Sprint Lens per user through the Marketplace, so it is Paid via Atlassian from version one."
},
"scopes": [
{
"scope": "read:sprint:jira-software",
"justification": "Reads sprint dates and state to draw the burn-up chart.",
"action": "keep"
},
{
"scope": "read:board-scope:jira-software",
"justification": "Lists the boards a project page can chart.",
"action": "keep"
},
{
"scope": "read:issue-details:jira",
"justification": "Reads issue estimates and status to count scope change and carry-over for each sprint.",
"action": "keep"
},
{
"scope": "storage:app",
"justification": "Keeps each sprint's forecast between page loads.",
"action": "keep"
}
],
"egress": [
{
"address": "forecast.sprintlens.example",
"inScopeEUD": "false",
"why": "When a sprint closes, the trigger sends anonymised sprint totals to the forecasting service."
}
],
"irreversibles": [
{
"item": "framework",
"decision": "Forge UI with the trigger and the forecasting host declared as egress; no Connect module.",
"owner": "Engineering lead",
"by": "before submission"
},
{
"item": "payment model",
"decision": "Paid via Atlassian from version one, priced per host user count.",
"owner": "Head of product",
"by": "before submission"
},
{
"item": "licensing",
"decision": "Set app.licensing.enabled to true and gate the page, the gadget and the trigger on the license.",
"owner": "Engineering lead",
"by": "before submission"
},
{
"item": "scopes",
"decision": "Keep the four scopes, each tied to the chart, the board list, the carry-over count or the stored forecast.",
"owner": "Engineering lead",
"by": "before submission"
},
{
"item": "egress",
"decision": "Keep forecast.sprintlens.example as the only host, with inScopeEUD false, and disclose it to buyers.",
"owner": "Security lead",
"by": "before submission"
},
{
"item": "app name",
"decision": "Keep the name Sprint Lens for Jira.",
"owner": "Marketing lead",
"by": "before submission"
}
],
"fixes": [
{
"check": "revenue_licensing",
"change": "Set app.licensing.enabled to true, check context.license.active in the resolver and filter.appIsLicensed on the sprint-closed trigger."
},
{
"check": "residency_egress",
"change": "Tell no-egress buyers which single host receives anonymised sprint totals and why, and offer AEC once it is under way."
},
{
"check": "timeline",
"change": "Move the launch past the planned approval, or launch without the listing and say the Marketplace listing follows."
}
],
"timeline": [
{
"step": "Enable licensing, gate the features, and answer every ticket point in one pass.",
"wait": "before resubmitting"
},
{
"step": "Resubmit; the review round runs again.",
"wait": "three weeks per round† (official figures range from one week to 15 business days†)"
},
{
"step": "Budget one more returned round.",
"wait": "three weeks†"
},
{
"step": "List on approval, after the current launch date.",
"wait": "when approved"
}
],
"footnote": "† verify against current Atlassian developer docs, because Atlassian changed its framework, badge and revenue-share rules within one year.",
"caveats": [
"Promote AEC only once an audit is under way; the input does not say whether one is.",
"Confirm the forecasting service receives no end-user data before keeping inScopeEUD false."
]
}
What step 5 prints for it: revenue_licensing | residency_egress | timeline
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./fgkit.js"); require("./fgreply.js")), then:
const K = FgKit, R = FgReply;
const f = K.form(Object.assign(K.blank(), { name: "Jira Notes", stage: "submitted", revenue: "yes", payment: "pva",
manifest: "app:\n id: ari:cloud:ecosystem::app/notes\nmodules:\n jira:issuePanel:\n - key: notes\n resource: main\npermissions:\n scopes: [read:jira-work, manage:jira-configuration]\n external:\n fetch:\n backend: ['*.notes.example']",
what: "Shows the notes linked to each issue in a panel." }));
// f.parsed (format, error with its line), f.m (the manifest), f.scopes (the scope table), f.egress, f.rows (the checks),
// f.diff (with a previous manifest), f.arch, f.pay, f.trust, f.timeline, f.plan (the stage's route and packet)
// K.scopesCsv(f), K.checksCsv(f), K.changeMd(f), K.board(f), K.applyPlan(f, K.board(f), planReply) - the exports
const b = K.buildInput(f, "plan"); // or "fixes" (needs ticket; 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://forge-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://forge-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"]
// forge-ready.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://forge-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://forge-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
}
// ForgeReady.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 ForgeReady {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://forge-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://forge-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://forge-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://forge-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":"forge-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": "forge-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: "forge-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": "forge-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", "forge-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: "forge-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" => "forge-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 = "forge-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", "app": "App name as listed: Jira Timesheets Pro; products: Jira
Stage: Forge app returned
...", "manifest": "manifest.yml (YAML), the fields the review reads:
...", "previous": "The previous version's manifest.yml (YAML), the fields the review reads:
...", "features": "What the app does:
...", "ticket": "", "carry": "", "facts": "The page's checks (...); verdict: returned as it stands; rule | id | status | evidence:
- 1 | connect_only | pass | ..."}
# - 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", "app": "App name as listed: Jira Timesheets Pro; products: Jira
Stage: Forge app returned
...", "manifest": "manifest.yml (YAML), the fields the review reads:
...", "previous": "The previous version's manifest.yml (YAML), the fields the review reads:
...", "features": "What the app does:
...", "ticket": "", "carry": "", "facts": "The page's checks (...); verdict: returned as it stands; rule | id | status | evidence:
- 1 | connect_only | pass | ..."}
# - 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", app, manifest, previous, features, ticket, 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 forge-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, decision, scopes, egress, irreversibles, fixes, 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 ticket point with its fix.
KEY="forge-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). \(.ticket) -> \(.fix)")
else (.fixes[] | "\(.check) \(.change)") end' reply.json
import hashlib, time
key = "forge-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["ticket"], "->", x["fix"])
print(reply["resubmit"]["major"])
else:
for x in reply["fixes"]:
print(x["check"], x["change"])
import { createHash } from "node:crypto";
const key = `forge-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.ticket, "->", x.fix); console.log(reply.resubmit.major); }
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 := "forge-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", "ticket", "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 = "forge-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("ticket").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 = "forge-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["ticket"]} -> #{x["fix"]}" }
else
reply["fixes"].each { |x| puts "#{x["check"]} #{x["change"]}" }
end
$key = "forge-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["ticket"], " -> ", $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 = $"forge-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!["ticket"]} -> {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: forge-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", "app": "App name as listed: Jira Timesheets Pro; products: Jira
Stage: Forge app returned
...", "manifest": "manifest.yml (YAML), the fields the review reads:
...", "previous": "The previous version's manifest.yml (YAML), the fields the review reads:
...", "features": "What the app does:
...", "ticket": "", "carry": "", "facts": "The page's checks (...); verdict: returned as it stands; rule | id | status | evidence:
- 1 | connect_only | pass | ..."}
# - 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": "forge-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": `forge-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", "forge-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", "forge-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" => "forge-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: forge-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", $"forge-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);