← Workspace Verify
API
Tokens

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.

StatusCodeMeaning
400validation_errorThe 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.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: both lanes are metered and need a personal sign-in.
404not_foundUnknown job id.
429rate_limitedBack off and retry.
5xxserver_errorTransient. 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:

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

FieldRequiredWhat
taskyesplan or fixes. If it is missing or neither, do plan and set "task": "plan".
integrationyesthe surface, the stage, every interview answer and the launch date with its distance in calendar days; "not given" means not given.
scopesyesthe 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.
featuresyeswhat the integration does, the feature notes (scope, what the feature does with it, read-only) and the scopes with no feature note.
reviewsend it (the review document for fixes, empty for plan)fixes task only: the review document or rejection email as pasted.
carrysend it (empty for plan)fixes task only: the verification plan's scope table and fixes, when the plan ran first; may be empty.
factsyesthe 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_notenooptional; 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:

House rules

The replies

plan

Plan verification and listing: the rung, one justification per scope, one fix per failing check and the sequence.

  1. summary - one sentence, at most 40 words: what blocks the gates today and what the plan changes.
  2. rung - an object: scopes (exactly the facts' highest tier: nonsensitive, sensitive or restricted), listing (exactly the facts' top listing rung: client_only, public_both or public_admin), why (one or two sentences from the interview and the checks).
  3. 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, narrow or drop), 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.
  4. 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.
  5. 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.
  6. footnote - exactly the footnote.
  7. 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.

  1. summary - one sentence, at most 40 words: what the review asked and what the resubmission changes.
  2. 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.
  3. resubmit - an object: gate (exactly the facts' gate: verification, casa or marketplace), clock (what pauses or restarts the clock: the review pauses until you reply), when (one sentence: fix every point, then resubmit once).
  4. 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.
  5. footnote - exactly the footnote.
  6. 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"}
}

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')

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}' 

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}' 

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

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