Run Workspace Verify 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 scopes from all three places, normalizes them to the short form, gives each a tier from the skill's ladder (or unknown), finds the drift, runs every check, ranks the listing ladder and dates the sequence, then hands it all to the run as facts. The run plans verification and listing or answers the rejection; the page then holds it to its own facts (gwreply.js). Workspace Verify is not affiliated with or endorsed by Google.
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":"gws-verify"}), 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, integration, scopes, features, facts) is missing. The page always sends all seven of task, integration, scopes, features, review, carry, facts; review and carry are empty strings for plan. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes, chosen by task, in the order the work runs - before you submit, then after a rejection:
plan- verification and listing planned: the page's highest tier and listing rung, one row per page scope with its tier, a justification, keep, narrow or drop and a narrower scope from the ladder, one fix per failing check, and the sequence with an owner per step and a dagger on every Google figure.fixes- the rejection answered: one fix per point the review document raises, in its order, the gate (verification, casa or marketplace), what pauses the clock and one resubmission, then the reply to the team - every point numbered, never arguing, never asking to expedite. Needs the review document inreview.
A missing or unknown task is answered as plan. Both lanes send the same seven 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 scope table into carry for the fixes lane. Scopes are always written in short form - no googleapis.com address in a reply.
The input
| Field | Required | What |
|---|---|---|
task | yes | plan or fixes. If it is missing or neither, do plan and set "task": "plan". |
integration | yes | the surface, the stage, every interview answer and the launch date with its distance in calendar days; "not given" means not given. |
scopes | yes | the page's scope table, one line per scope: the short form, the tier (nonsensitive, sensitive, restricted or unknown), where it is declared (code, sdk, consent) and where it is missing, read-only, and the narrower scopes the ladder names. |
features | yes | what the integration does, the feature notes (scope, what the feature does with it, read-only) and the scopes with no feature note. |
review | send it (the review document for fixes, empty for plan) | fixes task only: the review document or rejection email as pasted. |
carry | send it (empty for plan) | fixes task only: the verification plan's scope table and fixes, when the plan ran first; may be empty. |
facts | yes | the page's checks, one line each as rule | id | status | evidence, then the highest tier, the listing ladder, the dated sequence, the figures and the footnote. A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it. |
The rules the model applies
From the source skill, @samber/google-workspace-marketplace-playbook - the only standard the run applies:
- Scope: a SaaS vendor's integration with Google Workspace - a web app calling Google APIs, a Workspace add-on, an Editor add-on, a Chat app, a Drive app, or a Chrome extension's Google API scopes - through Google's two gates: OAuth app verification (Trust & Safety) and Marketplace listing review (the Marketplace team). Not listing copy, not the vendor's own API's OAuth, not internal-only apps.
- Rule 1, the scope tier sets the bill: non-sensitive needs brand verification only; sensitive needs verification, up to 10 days†; restricted needs verification over several weeks† plus a paid third-party CASA assessment, renewed every 12 months†, whenever restricted data can reach the vendor's servers.
- Rule 2, request only scopes the shipped features call, each with a written reason a narrower scope fails; take the read-only variant when the feature never writes. Future proofing is a policy breach.
- Rule 3, declare identical scopes in three places: code or manifest, the Marketplace SDK, the consent screen's Data Access. Any drift shows users the unverified-app screen or a second consent prompt, a top rejection cause.
- Rule 4, the listing cannot be approved before OAuth verification completes: run both in parallel; the slower one sets the date.
- Rule 5, Limited Use binds data from every sensitive or restricted Workspace scope: user-facing features only, no ads, no human reading without the user's consent for specific data, no training or improving AI models beyond that user's own personalized model. An in-app disclosure right before consent, and consent in context for each agentic capability before it first touches user data. The demo video must show explicit consent even for a personalized model.
- Rule 6, domain install removes the consent prompt, never the verification.
- Internal user type works only for users of the organization owning the Cloud project: a multi-customer SaaS cannot use it, and an Internal app fails Marketplace review. Testing status is a named rejection cause: switch to In production before submitting. An app showing sensitive or restricted scopes unverified caps at 100 users† over its lifetime, with no reset.
- The scope ladder, cheapest first. Non-sensitive: drive.file with the Google Picker, gmail.addons.current.message.action, gmail.addons.current.action.compose, gmail.labels, chat.bot, drive.install. Sensitive: gmail.send, gmail.addons.current.message.readonly, gmail.addons.current.message.metadata, most Calendar and Contacts scopes. Restricted: mail.google.com, gmail.readonly, gmail.modify, gmail.compose, gmail.metadata, gmail.settings.basic, drive, drive.readonly, drive.metadata.readonly, drive.activity, chat.messages, chat.messages.readonly. Take the restricted rung only when the core product cannot exist without it. Inside 6 weeks† of launch, restricted scopes leave the launch set when they serve secondary features; the date moves when the core product needs them.
- CASA applies when restricted data can reach a server outside Google. Google assigns AL1 or AL2†; the vendor cannot pick it, self-scans are deprecated, and Trust & Safety emails when to start, as the last step. Apps Script add-ons with no outbound scope (script.external_request) have been treated as local clients and spared it - practitioner reports, not Google policy.
- The listing ladder: a verified OAuth client with no listing (a web app only); a public listing with Individual + Admin install (the default, required for add-ons, Chat apps and Drive apps - Chat apps allow only this setting); a public listing with Admin Only install. Private listings serve only the publisher's own organization; visibility is permanent once saved.
- The sequence: the Cloud project inside an organization with two owners first; brand verification (publish branding within 7 days† of passing); the data access request with per-scope justifications and a demo video; CASA last, for restricted scopes; the Marketplace SDK, the listing and a reviewer test account with sample data in parallel; granular consent handled - detect missing scopes and re-prompt per feature; submit, and budget 2-4 weeks† end to end for sensitive scopes, longer for restricted. When rejected: fix every point in the review document, then resubmit once; the review pauses until you reply.
- Mark every Google figure you quote with † right after it, and close with the footnote: "† verify against current Google docs; Google renamed the security assessment's levels and moved verification timelines within a year."
House rules
- Use only the input. Never invent a scope, a tier, a feature, a date, a lab name, a user count or a review outcome; when the input does not say, write "not stated in the input".
- The page's verdicts are settled: the highest tier, the listing rung, every scope's tier and every check status. Never move them; explain them. An unknown tier stays unknown - say to read the API's scope page.
- Every Google figure carries † right after it, and the
footnotefield is exactly the footnote above. - No URL at all. Write every scope in its short form exactly as the scope table spells it (gmail.send, drive.file, mail.google.com), never as a googleapis.com address.
- Never imply that Google endorses, certifies or partners with the integration.
- Write in the language of the input; keep the JSON keys, check ids, tier ids, rung ids and scope ids exactly as given.
- Never use backticks, never write the input's field names as labels in your text, and never copy this prompt's wording into a reply.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
plan
Plan verification and listing: the rung, one justification per scope, one fix per failing check and the sequence.
summary- one sentence, at most 40 words: what blocks the gates today and what the plan changes.rung- an object:scopes(exactly the facts' highest tier:nonsensitive,sensitiveorrestricted),listing(exactly the facts' top listing rung:client_only,public_bothorpublic_admin),why(one or two sentences from the interview and the checks).scopes- exactly one object per scope in the scope table, in its order:scope(spelled exactly as the table),tier(exactly the table's),justification(how this integration uses it and why a narrower scope fails, only from the feature notes and the description - "needs a feature note" when they do not say; no two alike),action(keep,narrowordrop),narrower(a scope the ladder names, or ""). Drop a scope no shipped feature calls; narrow when the feature note says read-only or a cheaper rung serves it.fixes- one object per check the facts mark fail, and any warn worth fixing:check(the page's check id),change(one concrete sentence). Every fail check appears.sequence- the steps from here to the date, in order, each an object:step(one sentence),owner(a role, not a name),wait(the wait, with † on any Google figure). One rejection round is part of it.footnote- exactly the footnote.caveats- at most 4 short strings: what the team must confirm;[]when there is nothing to say.
{
"task": "plan",
"summary": "one sentence",
"rung": { "scopes": "restricted", "listing": "public_both", "why": "one or two sentences" },
"scopes": [ { "scope": "drive.file", "tier": "nonsensitive", "justification": "one or two sentences", "action": "keep", "narrower": "" } ],
"fixes": [ { "check": "drift", "change": "one sentence" } ],
"sequence": [ { "step": "one sentence", "owner": "Engineering lead", "wait": "one phrase" } ],
"footnote": "† verify against current Google docs; Google renamed the security assessment's levels and moved verification timelines within a year.",
"caveats": [ ]
}
The page holds it to its own facts (check() in gwreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL, googleapis.com addresses included; no backticks; a summary of at most 40 words; every Google figure with its dagger and the exact footnote; no claim of Google endorsement; no date the input does not have; the page's highest tier and listing rung (a different rung is put back and flagged); exactly one row per page scope and no invented one; every tier as the page has it (a changed tier is put back); actions keep, narrow or drop; a narrower scope only from the skill's ladder, and one for every narrow; a justification on every row and no two alike; a fix for every fail check and only the page's check ids; a sequence with an owner on every step.
fixes
Answer the rejection: one fix per point the review document raises, when to resubmit, and the reply to the team.
summary- one sentence, at most 40 words: what the review asked and what the resubmission changes.points- one object per distinct point the document raises, in its order:n(1, 2, 3...),review(a short quote or paraphrase of the point, from the document),fix(the change made, one sentence),check(the page's check id the point matches, or ""). When the carried plan has a row or a fix for it, use it.resubmit- an object:gate(exactly the facts' gate:verification,casaormarketplace),clock(what pauses or restarts the clock: the review pauses until you reply),when(one sentence: fix every point, then resubmit once).reply- the response to Trust & Safety or the Marketplace team: thanks them, lists every point as a numbered line with its fix, says every point is fixed in this one resubmission; polite, never argues, never asks to expedite, never claims Google endorsement, no URL.footnote- exactly the footnote.caveats- at most 4 short strings: what to confirm before sending;[]when there is nothing to say.
{
"task": "fixes",
"summary": "one sentence",
"points": [ { "n": 1, "review": "short quote or paraphrase", "fix": "one sentence", "check": "drift" } ],
"resubmit": { "gate": "verification", "clock": "one sentence", "when": "one sentence" },
"reply": "the reply text, one numbered line per point",
"footnote": "† verify against current Google docs; Google renamed the security assessment's levels and moved verification timelines within a year.",
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; the same text rules; at least as many points as the document numbers, only the page's check ids, a fix on each; the page's gate (a different gate is put back); a clock that says the review pauses until you reply; a when that resubmits once; 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 integrations, hosts on the reserved .example domain): the verification plan for a CRM mailbox sync with drifting restricted scopes, the answer to its rejection carrying that plan, and the plan for a Drive and Chat app launching inside the re-rank window. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the long fields, strings and lists are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.
plan - A CRM mailbox sync, rejected
The page read it as: fails a gate as it stands; stage rejected; highest tier restricted; listing public_both; scopes mail.google.com, drive, userinfo.email, gmail.readonly; fail checks: drift, limited_use_ai, testing_status, project_owner, granular, demo_video, test_account.
Input (facts is 5728 characters; the first 1398 are shown):
{
"task": "plan",
"integration": "Surface: a web app calling Google APIs; stage: rejected\nRestricted or sensitive data reaches our servers: yes; data reaches an AI model: training or improving shared models\nWho installs: both individual users and admins; multi-customer SaaS: yes; consent screen user type: external; OAuth publishing status: Testing\nCloud project owner: one person's account; granular consent handled: no; demo video ready: no; reviewer test account with sample data: no\nListing visibility saved: not saved yet; Apps Script outbound external_request scope: not an Apps Script add-on\nLaunch: not given",
"scopes": "The page's scope table (scope | tier | where declared | read-only | narrower scopes the ladder names), 4 scopes; highest known tier: restricted:\n- mail.google.com | restricted | declared in: code, consent | read-only: no | narrower in the ladder: gmail.readonly, gmail.send, gmail.addons.current.message.readonly\n- drive | restricted | declared in: code | missing from: consent | read-only: yes | read-only variant: drive.readonly | narrower in the ladder: drive.file, drive.readonly\n- userinfo.email | unknown | declared in: code, consent | read-only: not stated\n- gmail.readonly | restricted | declared in: consent | missing from: code | read-only: not stated | narrower in the ladder: gmail.addons.current.message.readonly, gmail.addons.current.message.metadata",
"features": "What the integration does: Pipewell CRM syncs the email a sales team exchanges with its contacts onto each deal's timeline, and attaches Drive files to deals. Mailbox content also trains Pipewell's reply-suggestion model, shared by every customer.\nFeature notes (scope | what the feature does with it | read-only):\n- mail.google.com | syncs every email exchanged with a CRM contact onto that deal's timeline | no\n- drive | attaches a Drive file to a deal from the deal page | yes\nScopes with no feature note: userinfo.email, gmail.readonly.",
"review": "",
"carry": "",
"facts": "The page's checks (7 fail, 6 warn, 3 info, 1 pass, 3 n/a); verdict: fails a gate as it stands; rule | id | status | evidence:\n- 3 | drift | fail | drive is missing from the consent screen; gmail.readonly is missing from code or manifest - drift shows users the unverified-app screen or a second consent prompt, a top rejection cause\n- 3 | lists_pasted | warn | not pasted: the Marketplace SDK - the page cannot compare it; all three must declare identical scopes\n- 2 | feature_notes | warn | no feature note for userinfo.email, gmail.readonly - request only scopes shipped features call; future proofing is a policy breach\n- 2 | readonly | warn | drive is noted read-only - drive.readonly is the read-only variant (restricted); the ladder's drive.file with the Google Picker is non-sensitive\n- 1 | tier_known | warn | userinfo.email is not in the skill's ladder - read the API's scope page for the tier; the page never guesses\n- 1 | restricted | warn | mail.google.com, drive, gmail.readonly - restricted: verification over several weeks† plus a paid CASA assessment, renewed every 12 months†, whenever restricted data can reach your servers; take the rung only when the core product cannot exist without it\n- 1 | casa | info | restricted data reaches your servers (interview) - CASA applies: Google assigns AL1 or AL2†, an authorized lab runs it, revalidated every 12 months†; it starts on Trust &\n\n[... 4330 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "plan",
"summary": "Drifting scopes, Testing status, a one-person project, a shared model trained on mailboxes and no video block both gates; the plan stops the training, narrows drive, reconciles three identical lists and resubmits once.",
"rung": {
"scopes": "restricted",
"listing": "public_both",
"why": "Syncing every contact email onto a deal timeline needs a whole-mailbox scope, so the restricted rung stays. Admins and individual users both install Pipewell CRM, which takes a public listing with Individual + Admin install."
},
"scopes": [
{
"scope": "mail.google.com",
"tier": "restricted",
"justification": "mail.google.com lets the mailbox sync put every email exchanged with a CRM contact onto that deal's timeline. The feature note names no write, so gmail.readonly, which reads every message without send or delete rights, is the narrower restricted scope to move [... 4 more characters cut from this display]",
"action": "narrow",
"narrower": "gmail.readonly"
},
{
"scope": "drive",
"tier": "restricted",
"justification": "drive lets a user attach a Drive file to a deal from the deal page. The feature only reads the one file the user attaches, so drive.file with the Google Picker serves it at the non-sensitive tier and full Drive access is not needed.",
"action": "narrow",
"narrower": "drive.file"
},
{
"scope": "userinfo.email",
"tier": "unknown",
"justification": "needs a feature note - the description does not say which feature reads the account email; read the API's scope page for its tier.",
"action": "keep",
"narrower": ""
},
{
"scope": "gmail.readonly",
"tier": "restricted",
"justification": "needs a feature note - the consent screen lists it but the code never requests it, so no shipped feature calls it today.",
"action": "drop",
"narrower": ""
}
],
"fixes": [
{
"check": "drift",
"change": "Paste the same reconciled list - gmail.readonly, drive.file and userinfo.email - into the code, the Marketplace SDK and the consent screen's Data Access, so no scope appears in one place only."
},
{
"check": "limited_use_ai",
"change": "Stop training the shared reply-suggestion model on mailbox content and update the privacy policy to say Gmail data serves only user-facing features."
},
{
"check": "testing_status",
"change": "Switch the OAuth publishing status from Testing to In production before resubmitting."
},
{
"check": "project_owner",
"change": "Move the Cloud project into the company's organization with two owners and billing enabled before anything else."
},
{
"check": "granular",
"change": "Detect each scope the user did not grant and re-prompt for it from the feature that needs it, so the sync and the attachments keep working on a partial grant."
}
],
"sequence": [
{
"step": "Move the Cloud project into the organization with two owners and billing enabled.",
"owner": "Cloud admin",
"wait": "before anything else"
},
{
"step": "Stop the shared-model training, reconcile the three scope lists and switch the project to In production.",
"owner": "Engineering lead",
"wait": "before the resubmission"
},
{
"step": "Handle granular consent, then record the demo video with one segment per restricted scope.",
"owner": "Engineering",
"wait": "before the resubmission"
},
{
"step": "Resubmit the data access request once, with one justification per scope.",
"owner": "Engineering lead",
"wait": "verification over several weeks†"
},
{
"step": "In parallel, configure the Marketplace SDK with the same scopes, build the listing and set up the reviewer test account.",
"owner": "Product",
"wait": "budget 2-4 weeks† end to end, approved only after verification"
}
],
"footnote": "† verify against current Google docs; Google renamed the security assessment's levels and moved verification timelines within a year.",
"caveats": [
"Confirm the mailbox sync never sends, deletes or labels mail before moving from mail.google.com to gmail.readonly.",
"Confirm which feature reads the account email; userinfo.email has no feature note.",
"Until verified, the app stays capped at 100 users† over its lifetime."
]
}
Cut from this display only (the saved reply and the check use the full text): scopes[0].justification (263 characters, 259 shown); fixes (8 items, 5 shown); sequence (8 items, 5 shown).
What step 5 prints for it: mail.google.com [restricted] narrow | drive [restricted] narrow | userinfo.email [unknown] keep | gmail.readonly [restricted] drop
The page's check: kept - nothing to flag
fixes - A CRM mailbox sync, rejected
The input carries the saved plan (R.laneForm fills carry from the plan reply), as the page's handoff does, and the review document, which numbers 4 points; the page reads the verification gate.
Input (carry is 2217 characters; the first 1390 are shown; facts is 5653 characters; the first 1398 are shown):
{
"task": "fixes",
"integration": "Surface: a web app calling Google APIs; stage: rejected\nRestricted or sensitive data reaches our servers: yes; data reaches an AI model: training or improving shared models\nWho installs: both individual users and admins; multi-customer SaaS: yes; consent screen user type: external; OAuth publishing status: Testing\nCloud project owner: one person's account; granular consent handled: no; demo video ready: no; reviewer test account with sample data: no\nListing visibility saved: not saved yet; Apps Script outbound external_request scope: not an Apps Script add-on\nLaunch: not given",
"scopes": "The page's scope table (scope | tier | where declared | read-only | narrower scopes the ladder names), 4 scopes; highest known tier: restricted:\n- mail.google.com | restricted | declared in: code, consent | read-only: no | narrower in the ladder: gmail.readonly, gmail.send, gmail.addons.current.message.readonly\n- drive | restricted | declared in: code | missing from: consent | read-only: yes | read-only variant: drive.readonly | narrower in the ladder: drive.file, drive.readonly\n- userinfo.email | unknown | declared in: code, consent | read-only: not stated\n- gmail.readonly | restricted | declared in: consent | missing from: code | read-only: not stated | narrower in the ladder: gmail.addons.current.message.readonly, gmail.addons.current.message.metadata",
"features": "What the integration does: Pipewell CRM syncs the email a sales team exchanges with its contacts onto each deal's timeline, and attaches Drive files to deals. Mailbox content also trains Pipewell's reply-suggestion model, shared by every customer.\nFeature notes (scope | what the feature does with it | read-only):\n- mail.google.com | syncs every email exchanged with a CRM contact onto that deal's timeline | no\n- drive | attaches a Drive file to a deal from the deal page | yes\nScopes with no feature note: userinfo.email, gmail.readonly.",
"review": "The review document:\nSubject: OAuth verification for Pipewell CRM - action needed\n\nHello,\n\nThank you for submitting Pipewell CRM for OAuth app verification. Our review found the following issues:\n\n1. The scopes your app requests do not match the scopes on your OAuth consent screen. The app requests https://www.googleapis.com/auth/drive, which is not listed, and the consent screen lists gmail.readonly, which the app does not request.\n2. Your project is still in Testing publishing status. Set it to In production before requesting verification.\n3. We could not confirm that your use of Gmail data meets the Limited Use requirements: your privacy policy says email content improves a model used for all customers.\n4. Provide a demo video that shows the full OAuth grant flow and how the app uses each requested restricted scope.\n\nReply to this email once you have addressed these points.\n\nThe OAuth review team",
"carry": "The verification plan (restricted, public_both), one row per scope:\n- mail.google.com (restricted, narrow to gmail.readonly): mail.google.com lets the mailbox sync put every email exchanged with a CRM contact onto that deal's timeline. The feature note names no write, so gmail.readonly, which reads every message without send or delete rights, is the narrower restricted scope to move to.\n- drive (restricted, narrow to drive.file): drive lets a user attach a Drive file to a deal from the deal page. The feature only reads the one file the user attaches, so drive.file with the Google Picker serves it at the non-sensitive tier and full Drive access is not needed.\n- userinfo.email (unknown, keep): needs a feature note - the description does not say which feature reads the account email; read the API's scope page for its tier.\n- gmail.readonly (restricted, drop): needs a feature note - the consent screen lists it but the code never requests it, so no shipped feature calls it today.\nFixes, one per failing check:\n- drift: Paste the same reconciled list - gmail.readonly, drive.file and userinfo.email - into the code, the Marketplace SDK and the consent screen's Data Access, so no scope appears in one place only.\n- limited_use_ai: Stop training the shared reply-suggestion model on mailbox content and update the privacy policy to say Gmail data serves only user-facing features.\n-\n\n[... 827 more characters cut from this display ...]",
"facts": "The page's checks (7 fail, 6 warn, 3 info, 1 pass, 3 n/a); verdict: fails a gate as it stands; rule | id | status | evidence:\n- 3 | drift | fail | drive is missing from the consent screen; gmail.readonly is missing from code or manifest - drift shows users the unverified-app screen or a second consent prompt, a top rejection cause\n- 3 | lists_pasted | warn | not pasted: the Marketplace SDK - the page cannot compare it; all three must declare identical scopes\n- 2 | feature_notes | warn | no feature note for userinfo.email, gmail.readonly - request only scopes shipped features call; future proofing is a policy breach\n- 2 | readonly | warn | drive is noted read-only - drive.readonly is the read-only variant (restricted); the ladder's drive.file with the Google Picker is non-sensitive\n- 1 | tier_known | warn | userinfo.email is not in the skill's ladder - read the API's scope page for the tier; the page never guesses\n- 1 | restricted | warn | mail.google.com, drive, gmail.readonly - restricted: verification over several weeks† plus a paid CASA assessment, renewed every 12 months†, whenever restricted data can reach your servers; take the rung only when the core product cannot exist without it\n- 1 | casa | info | restricted data reaches your servers (interview) - CASA applies: Google assigns AL1 or AL2†, an authorized lab runs it, revalidated every 12 months†; it starts on Trust &\n\n[... 4255 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "fixes",
"summary": "The review found drifting scopes, Testing status, a Limited Use breach in model training and no demo video; the resubmission fixes all four together and resubmits once.",
"points": [
{
"n": 1,
"review": "The scopes the app requests do not match the consent screen: drive is requested but not listed, gmail.readonly is listed but not requested.",
"fix": "The code, the Marketplace SDK and the consent screen now declare the same list - gmail.readonly, drive.file and userinfo.email - with drive replaced by drive.file and the Google Picker.",
"check": "drift"
},
{
"n": 2,
"review": "The project is still in Testing publishing status.",
"fix": "The OAuth publishing status is now In production.",
"check": "testing_status"
},
{
"n": 3,
"review": "The privacy policy says email content improves a model used for all customers, which does not meet Limited Use.",
"fix": "Mailbox content no longer trains the shared reply-suggestion model, and the privacy policy now says Gmail data serves only user-facing features.",
"check": "limited_use_ai"
},
{
"n": 4,
"review": "Provide a demo video showing the full grant flow and how the app uses each restricted scope.",
"fix": "A new demo video shows the full grant flow from the Connect button and one segment for the mailbox sync.",
"check": "demo_video"
}
],
"resubmit": {
"gate": "verification",
"clock": "The review pauses until you reply to this email; nothing moves while a point is open.",
"when": "Fix all four points, then reply and resubmit once, in this thread."
},
"reply": "Hello,\n\nThank you for reviewing Pipewell CRM. We have addressed each point:\n\n1. Scope mismatch: the code, the Marketplace SDK and the consent screen now declare the same scopes - gmail.readonly, drive.file and userinfo.email. Drive attachments now use [... 505 more characters cut from this display]",
"footnote": "† verify against current Google docs; Google renamed the security assessment's levels and moved verification timelines within a year.",
"caveats": [
"Send this only once the reconciled scopes are live in the code, the Marketplace SDK and the consent screen.",
"Move the Cloud project into the organization before resubmitting; the review keys on that project.",
"Confirm the privacy policy change is published before you reply."
]
}
Cut from this display only (the saved reply and the check use the full text): reply (756 characters, 251 shown).
What step 5 prints for it: 1 drift | 2 testing_status | 3 limited_use_ai | 4 demo_video
The page's check: kept - nothing to flag
plan - A Drive and Chat app with a CASA email
The page read it as: fails a gate as it stands; stage CASA; highest tier restricted; listing public_both; scopes drive.file, drive.install, chat.messages, chat.bot; fail checks: chat_install.
Input (facts is 5055 characters; the first 1395 are shown):
{
"task": "plan",
"integration": "Surface: a Chat app; stage: CASA\nRestricted or sensitive data reaches our servers: yes; data reaches an AI model: only the user's own personalized model\nWho installs: admins, domain-wide; multi-customer SaaS: yes; consent screen user type: external; OAuth publishing status: In production\nCloud project owner: an organization with two or more owners; granular consent handled: yes; demo video ready: yes; reviewer test account with sample data: no\nListing visibility saved: not saved yet; Apps Script outbound external_request scope: not an Apps Script add-on\nLaunch: 2026-12-01 (30 calendar days from today)",
"scopes": "The page's scope table (scope | tier | where declared | read-only | narrower scopes the ladder names), 4 scopes; highest known tier: restricted:\n- drive.file | nonsensitive | declared in: code, sdk, consent | read-only: no\n- drive.install | nonsensitive | declared in: code, sdk, consent | read-only: no\n- chat.messages | restricted | declared in: code, sdk, consent | read-only: yes | read-only variant: chat.messages.readonly | narrower in the ladder: chat.bot, chat.messages.readonly\n- chat.bot | nonsensitive | declared in: code, sdk, consent | read-only: no",
"features": "What the integration does: Minutely turns a meeting's agenda doc into minutes: users open the doc from Drive with Open with, and the Chat app posts the summary and drafts action items from the space's thread. Each user's past minutes tune a summary model used only for that user.\nFeature notes (scope | what the feature does with it | read-only):\n- drive.file | opens the agenda doc the user picks with Open with and writes the minutes into it | no\n- drive.install | registers Minutely in the Drive Open with menu | no\n- chat.bot | posts the minutes summary into the space when someone mentions Minutely | no\n- chat.messages | reads the space's thread to draft action items from the discussion | yes\nScopes with no feature note: none.",
"review": "",
"carry": "",
"facts": "The page's checks (1 fail, 6 warn, 3 info, 9 pass, 1 n/a); verdict: fails a gate as it stands; rule | id | status | evidence:\n- 3 | drift | pass | the 3 pasted lists declare the same 4 scopes\n- 3 | lists_pasted | pass | code, Marketplace SDK and consent screen all pasted\n- 2 | feature_notes | pass | a feature note for each of the 4 scopes\n- 2 | readonly | warn | chat.messages is noted read-only - chat.messages.readonly is the read-only variant (restricted)\n- 1 | tier_known | pass | every scope is in the ladder\n- 1 | restricted | warn | chat.messages - restricted: verification over several weeks† plus a paid CASA assessment, renewed every 12 months†, whenever restricted data can reach your servers; take the rung only when the core product cannot exist without it\n- 1 | casa | info | restricted data reaches your servers (interview) - CASA applies: Google assigns AL1 or AL2†, an authorized lab runs it, revalidated every 12 months†; it starts on Trust & Safety's email, as the last step\n- 1 | script_outbound | na | not an Apps Script add-on\n- 5 | limited_use_ai | warn | data reaches only the user's own personalized model (interview) - allowed, but the demo video must show the user's explicit consent to it\n- 5 | limited_use | info | Limited Use binds the data from chat.messages: user-facing features only, no ads, no human reading without consent; an in-app disclosure right before\n\n[... 3660 more characters cut from this display ...]"
}
Reply (shortened for display - see the cuts below):
{
"task": "plan",
"summary": "The Chat install setting fails review and chat.messages puts CASA on the path to a launch inside 6 weeks†; the plan sets Individual + Admin install and takes the secondary restricted feature out of the launch set.",
"rung": {
"scopes": "restricted",
"listing": "public_both",
"why": "chat.messages is restricted and serves the action-item drafting, a secondary feature; the minutes and the summary run on non-sensitive scopes. A Chat app lives only in a public listing, and Chat apps allow only Individual + Admin install."
},
"scopes": [
{
"scope": "drive.file",
"tier": "nonsensitive",
"justification": "drive.file lets Minutely open the agenda doc the user picks with Open with and write the minutes into it. It reaches only the file the user opens, the narrowest Drive scope that can write into it.",
"action": "keep",
"narrower": ""
},
{
"scope": "drive.install",
"tier": "nonsensitive",
"justification": "drive.install registers Minutely in the Drive Open with menu, so a user can open an agenda doc with it. No other scope adds the app to that menu.",
"action": "keep",
"narrower": ""
},
{
"scope": "chat.messages",
"tier": "restricted",
"justification": "chat.messages lets the Chat app read the space's thread to draft action items from the discussion. The feature only reads, so chat.messages.readonly is its read-only variant, still restricted; inside 6 weeks† of launch this secondary feature leaves the launch [... 5 more characters cut from this display]",
"action": "drop",
"narrower": ""
},
{
"scope": "chat.bot",
"tier": "nonsensitive",
"justification": "chat.bot lets the Chat app post the minutes summary into the space when someone mentions Minutely, authenticating as the app itself. It needs no access to the space's other messages.",
"action": "keep",
"narrower": ""
}
],
"fixes": [
{
"check": "chat_install",
"change": "Set the Marketplace SDK install setting to Individual + Admin install, the only setting a Chat app allows."
},
{
"check": "readonly",
"change": "If the action-item drafting returns after launch, request chat.messages.readonly rather than chat.messages, with its own justification."
},
{
"check": "launch_window",
"change": "Launch with drive.file, drive.install and chat.bot only, and bring the action-item drafting back after its own verification."
},
{
"check": "limited_use_ai",
"change": "Show the user's explicit consent to the personalized summary model in the demo video and in the app before it first uses their minutes."
}
],
"sequence": [
{
"step": "Set the install setting to Individual + Admin and remove chat.messages from the code, the Marketplace SDK and the consent screen.",
"owner": "Engineering lead",
"wait": "before anything else"
},
{
"step": "Reply to the verification email with the reduced scope list; with no restricted scope left, the CASA step leaves the launch path.",
"owner": "Engineering lead",
"wait": "the review pauses until you reply"
},
{
"step": "Build the listing with screenshots of the real app and set up a reviewer test account with sample agenda docs.",
"owner": "Product",
"wait": "in parallel with verification"
},
{
"step": "Submit for Marketplace review once verification completes.",
"owner": "Product",
"wait": "budget 2-4 weeks† end to end"
},
{
"step": "Budget one rejection round: fix every point, then resubmit once.",
"owner": "Engineering lead",
"wait": "up to 10 days† for a re-review"
},
{
"step": "After launch, request chat.messages.readonly for the action items and plan CASA for it.",
"owner": "Security lead",
"wait": "verification over several weeks†, then CASA every 12 months†"
}
],
"footnote": "† verify against current Google docs; Google renamed the security assessment's levels and moved verification timelines within a year.",
"caveats": [
"Confirm the action-item drafting is secondary; if the core product needs it, the launch date moves instead.",
"The page's sequence with chat.messages kept runs past the launch date; dropping it is what keeps the date.",
"Ask Trust & Safety whether the pending CASA request closes once the restricted scope is withdrawn."
]
}
Cut from this display only (the saved reply and the check use the full text): scopes[2].justification (264 characters, 259 shown).
What step 5 prints for it: drive.file [nonsensitive] keep | drive.install [nonsensitive] keep | chat.messages [restricted] drop | chat.bot [nonsensitive] keep
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./gwkit.js"); require("./gwreply.js")), then:
const K = GwKit, R = GwReply;
const f = K.form(Object.assign(K.blank(), { surface: "web", stage: "building", owner: "person",
code: "const SCOPES = ['https://www.googleapis.com/auth/gmail.send', 'https://www.googleapis.com/auth/drive'];",
consent: "gmail.send",
notes: "gmail.send | sends a follow-up from the deal page | no\ndrive | attaches a file to a deal | yes" }));
// f.rows (the scope table), f.checks, f.ladder (do you need a listing), f.seq (the dated sequence)
// K.scopesCsv(f), K.checksCsv(f), K.threePlace(f), K.board(f) - the exports
const b = K.buildInput(f, "plan"); // or "fixes" (needs review; 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://gws-verify.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://gws-verify.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"]
// gws-verify.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://gws-verify.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://gws-verify.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
}
// GwsVerify.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 GwsVerify {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://gws-verify.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://gws-verify.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://gws-verify.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://gws-verify.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":"gws-verify"}' | 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": "gws-verify"})["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: "gws-verify" })).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": "gws-verify"}, 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", "gws-verify"), 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: "gws-verify" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "gws-verify"])["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 = "gws-verify" }))!["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", "integration": "Surface: a web app calling Google APIs; stage: rejected
...", "scopes": "The page's scope table (...):
- mail.google.com | restricted | declared in: code, consent | ...
...", "features": "What the integration does: Pipewell CRM syncs ...
...", "review": "", "carry": "", "facts": "The page's checks (...); verdict: fails a gate as it stands; rule | id | status | evidence:
- 3 | drift | 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", "integration": "Surface: a web app calling Google APIs; stage: rejected
...", "scopes": "The page's scope table (...):
- mail.google.com | restricted | declared in: code, consent | ...
...", "features": "What the integration does: Pipewell CRM syncs ...
...", "review": "", "carry": "", "facts": "The page's checks (...); verdict: fails a gate as it stands; rule | id | status | evidence:
- 3 | drift | 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", integration, scopes, features, review, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses gws-verify:<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, rung, scopes, fixes, sequence, footnote and caveats; a fixes reply carries summary, points, resubmit, reply, footnote and caveats. The code below prints the gist of either: each scope of the plan with its tier and action, or each review point with its fix.
KEY="gws-verify:$(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). \(.review) -> \(.fix)")
else (.scopes[] | "\(.scope) [\(.tier)] \(.action)") end' reply.json
import hashlib, time
key = "gws-verify:" + 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["review"], "->", x["fix"])
print(reply["resubmit"]["gate"])
else:
for x in reply["scopes"]:
print(x["scope"], x["tier"], x["action"])
import { createHash } from "node:crypto";
const key = `gws-verify:${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.review, "->", x.fix); console.log(reply.resubmit.gate); }
else { for (const x of reply.scopes) console.log(x.scope, x.tier, x.action); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "gws-verify:" + 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 := "scopes", "scope", "action"
if reply["task"] == "fixes" {
key, name, field = "points", "review", "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 = "gws-verify:" + 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("review").asText() + " -> " + x.path("fix").asText());
} else {
for (JsonNode x : reply.path("scopes")) System.out.println(x.path("scope").asText() + " " + x.path("action").asText());
}
require "digest"
key = "gws-verify:#{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["review"]} -> #{x["fix"]}" }
else
reply["scopes"].each { |x| puts "#{x["scope"]} #{x["action"]}" }
end
$key = "gws-verify:" . $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["review"], " -> ", $x["fix"], "\n"; }
else { foreach ($reply["scopes"] as $x) echo $x["scope"], " ", $x["action"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"gws-verify:{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!["review"]} -> {x["fix"]}"); }
else { foreach (var x in reply["scopes"]!.AsArray()) Console.WriteLine($"{x!["scope"]} {x["action"]}"); }
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: gws-verify:$(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", "integration": "Surface: a web app calling Google APIs; stage: rejected
...", "scopes": "The page's scope table (...):
- mail.google.com | restricted | declared in: code, consent | ...
...", "features": "What the integration does: Pipewell CRM syncs ...
...", "review": "", "carry": "", "facts": "The page's checks (...); verdict: fails a gate as it stands; rule | id | status | evidence:
- 3 | drift | 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": "gws-verify:" + 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": `gws-verify:${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", "gws-verify:"+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", "gws-verify:" + 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" => "gws-verify:#{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: gws-verify:" . 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", $"gws-verify:{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);