← OAuth Surface
API
Tokens

Run OAuth Surface 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 grants (from the discovery document when one is pasted), checks PKCE, redirect matching and every registered redirect URI, the code, access and refresh lifetimes, lints every scope and its consent sentence, puts each scope in a review tier, checks registration and verification, and ranks the refresh, granularity and registration rungs, then hands it all to the run as facts. The run writes the core or the program; the page then holds it to its own facts (oareply.js): the ranked rungs, implicit and password removed, PKCE S256 for all clients, exact redirects, short single-use codes, every listed scope covered with its own consent sentence and other users' data kept in the assessment tier; the registration rung, an identity tier and a renewed assessment, a testing lane of at most 100 users and 7-day test tokens, a runtime subset check, and gates in all four areas.

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":"oauth-surface"}), 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, provider, surface, facts) is missing. The page always sends all five of task, provider, surface, carry, facts; carry is an empty string for core.
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 - the server, then the apps:

A missing or unknown task is answered as core. Both lanes send the same five 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 core's decisions into carry for the program lane.

The input

FieldRequiredWhat
taskyescore or program. If it is missing or neither, do core and set "task": "core".
provideryesthe platform's name and purpose and the interview: greenfield or retrofit and the registered apps, the clients, whether apps act as themselves, the data-sensitivity ceiling, who approves an install, the substrate, the purpose, the timing; "not given" means not given.
surfaceyesthe grants, PKCE, redirect matching and the registered redirect URIs, the token lifetimes and refresh posture, the scope table (scope, consent copy, token type, whose data - own, others for other users' or bulk data, regulated for financial or health data - and the page's tier), consent, registration and verification.
carrysend it (empty for core)program task only: the core's decisions, when the core task ran first; may be empty.
factsyesthe page's checks, one line each as area | id | status | evidence, and the rungs the page ranked. 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/oauth2-provider-design - the only standard the run applies:

House rules

The replies

core

Write the provider core.

  1. summary - one sentence, at most 40 words: what lets a leaked token or a compromised app spread today, and the first fix.
  2. grants - one object per grant: grant (authorization_code, client_credentials, refresh_token, device_code, implicit, password, ...), verdict (keep for a grant offered today that stays, add for one the design adds, remove for one offered today that goes), why (one sentence). Every grant offered today appears; implicit and password, when offered, are removed.
  3. pkce - method (S256), clients (all), why (one sentence).
  4. redirects - matching (exact), loopback_port (true when native apps redirect to loopback), why (one sentence).
  5. tokens - code_ttl_s (at most 600), code_single_use (true), access_ttl_min (about 60, at most 90), access_format (opaque or jwt), why (one sentence).
  6. refresh - rung (the facts' rung: rotation, dpop or nonrotating), reuse_detection (true or false), grace_s (10 to 30 whenever refresh tokens rotate - under the dpop rung too, when they still rotate - else 0), spa_ttl_h (at most 24 when browser apps exist, else 0), why (one sentence).
  7. granularity - rung (the facts' rung: resource-action or fine), why (one sentence).
  8. scopes - the new scope table, one object per scope: scope (resource:action), replaces (the listed scopes whose grants move to this one, exactly as listed; [] for a scope that keeps its name and absorbs nothing, or a new one), copy (the consent sentence a user reads - plain language, never the scope string), token (user, app or both), access (read or write), tier (identity, attestation or assessment - a scope reaching other users' or regulated data is assessment). Every listed scope is covered - kept under its own name, or named in the replaces of each scope that takes it over; a catch-all is split across the narrow scopes that replace it, each naming it in its replaces.
  9. consent - partial (true), diff (one sentence: the server-side diff and what an integrating app learns from it), incremental (true), delta_only (true), admin (one sentence on the admin surface when org admins approve installs, else "").
  10. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "core",
  "summary": "one sentence",
  "grants": [ { "grant": "authorization_code", "verdict": "keep", "why": "one sentence" } ],
  "pkce": { "method": "S256", "clients": "all", "why": "one sentence" },
  "redirects": { "matching": "exact", "loopback_port": true, "why": "one sentence" },
  "tokens": { "code_ttl_s": 300, "code_single_use": true, "access_ttl_min": 60, "access_format": "opaque", "why": "one sentence" },
  "refresh": { "rung": "rotation", "reuse_detection": true, "grace_s": 20, "spa_ttl_h": 24, "why": "one sentence" },
  "granularity": { "rung": "resource-action", "why": "one sentence" },
  "scopes": [ { "scope": "files:read", "replaces": [ "files" ], "copy": "See the files in your workspace", "token": "user", "access": "read", "tier": "identity" } ],
  "consent": { "partial": true, "diff": "one sentence", "incremental": true, "delta_only": true, "admin": "" },
  "caveats": [ ]
}

The page holds it to its own facts (check() in oareply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL; no backticks; OAuth 2.1 never called a published RFC; no consent conversion percentage; every grant offered today listed, implicit and password removed, authorization_code and refresh_token kept or added (client_credentials when apps act as themselves, device_code for native or agent clients); PKCE S256 for all clients; exact redirect matching; codes of at most 600 seconds, single-use; access tokens of 5 to 90 minutes; the ranked refresh and granularity rungs; under rotation, reuse detection and a 10 to 30 second grace window; browser refresh tokens of at most 24 hours; every listed scope covered once by name or in a replaces, no stray replaces, no duplicates; resource:action names, no catch-all, a consent sentence that is not the raw scope string, a token type (user only when apps act only for users), read or write, a review tier - assessment for any scope reaching other users' or regulated data; write never without its read scope, and at least one read scope; partial grants with a server-side diff, incremental consent showing only the new scope, and an admin surface where org admins approve.

program

Write the registration and verification program.

  1. summary - one sentence, at most 40 words.
  2. registration - rung (the facts' rung: manual or gated-dcr), why (one sentence), metadata (the registration rules, as short strings - for gated-dcr the initial access token, the rate limit and metadata treated as untrusted).
  3. tiers - one object per review tier in use: tier (identity, attestation or assessment), scopes (the scopes it covers - every scope reaching other users' data under assessment, or the scope the carried core gave in its place), requirement (one sentence), recurrence (when it is renewed - the assessment every 12 months).
  4. testing - user_cap (at most 100), token_days (at most 7), warning (one sentence: what an unverified app's users see).
  5. enforcement - one sentence: the runtime check that rejects a scope request outside the approved set.
  6. badges - one sentence: how identity, attestation and audit are shown as distinct signals.
  7. gates - four or more objects: area (protocol, tokens, consent or verification - all four covered), gate (one sentence), test (one sentence: how it is proven).
  8. metrics - two to four strings: the trends to baseline from launch.
  9. rollout - three to six strings, in order: how the program and the core reach the apps already registered.
  10. caveats - at most 4 short strings; [] when there is nothing to say.
{
  "task": "program",
  "summary": "one sentence",
  "registration": { "rung": "manual", "why": "one sentence", "metadata": [ "a rule" ] },
  "tiers": [ { "tier": "identity", "scopes": [ ], "requirement": "one sentence", "recurrence": "once, re-checked on publisher changes" } ],
  "testing": { "user_cap": 100, "token_days": 7, "warning": "one sentence" },
  "enforcement": "one sentence",
  "badges": "one sentence",
  "gates": [ { "area": "protocol", "gate": "one sentence", "test": "one sentence" } ],
  "metrics": [ "median app-review turnaround" ],
  "rollout": [ "a step" ],
  "caveats": [ ]
}

The page requires: the reply answers the task it was sent; no URL; no backticks; no consent conversion percentage; the ranked registration rung and its rules (for gated dynamic registration: an initial access token, a rate limit, untrusted metadata); an identity tier; where scopes reach other users' data, an assessment tier renewed every 12 months that covers each such scope or the scope the carried core gave in its place, and every scope the carried core put in that tier; a testing lane of at most 100 users with test tokens of at most 7 days and a warning screen; a runtime subset check; distinct trust badges; gates covering protocol, tokens, consent and verification, each with a test; two to four metrics; three to six rollout steps.

Worked examples

Three runs from the page's free samples (invented platforms; every redirect host uses the reserved .example domain): the core for a task SaaS retrofitting OAuth in weeks, the program for the same SaaS carrying that core, and the core for a platform whose clients are AI agents and MCP hosts. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the facts and long strings 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.

core - A task SaaS retrofitting OAuth in weeks

The page read it as: a leaked token would spread (10 fail, 9 warn, 10 pass); refresh by rank: nonrotating; granularity by rank: resource-action; registration by rank: manual; failing checks: implicit, password, matching, redirects, catchall, copy, binding, admin, assessment, subset.

Input (surface is 1419 characters; the first 1397 are shown; facts is 3547 characters; the first 1396 are shown):

{
 "task": "core",
 "provider": "Platform: Taskmoor\nWhat it does: Project and task management for agencies; partner apps sync tasks, comments and time entries for workspace members, and some run as bots that post status updates.\nMode: retrofit, 2400 registered apps\nClients: confidential server apps\nApps act: Also with their own identity (app tokens, org-wide access)\nData-sensitivity ceiling: Other users' data or bulk data\nWho approves an install: End users, and org admins can gate installs\nSubstrate: An authorization server built from scratch\nPurpose: The foundation of an app ecosystem\nTiming: Must ship within weeks",
 "surface": "Grants offered: authorization_code, implicit, password, refresh_token, client_credentials (read from the discovery document)\nPKCE: Required for public clients only; redirect matching: Prefix match; bearer tokens in query strings: no\nRegistered redirect URIs:\n- https://app.brightsync.example/oauth/callback\n- https://*.hookvane.example/taskmoor/callback\n- http://reports.clockrill.example/cb\nAuthorization code: 10 minutes, single-use yes; access token: 480 minutes, jwt\nRefresh tokens: Non-rotating, no cap; reuse detection: no; grace window: not given; browser refresh lifetime: not given\nScopes (scope | consent copy | token type | whose data | page's tier):\n- tasks:read | See the tasks in your workspaces | token type not given | others | tier assessment\n- tasks:write | Create and change tasks in your workspaces | token type not given | others | tier assessment\n- comments:write | comments:write | token type not given | others | tier assessment\n- full_access | Full access to your account | token type not given | others | tier assessment\n- time:read | See the time you logged | user | own | tier identity\nConsent: partial grants no, server-side diff no, incremental no, re-consent shows only the new scope no, admin consent surface no\nRegistration: Developer portal with a review step; verification: Publisher identity verification; testing lane: no cap; runtime subset check: no; one badge\n\n[... 22 more characters cut from this display ...]",
 "carry": "",
 "facts": "The page's checks (10 fail, 9 warn, 10 pass, 0 n/a):\n- Protocol core | implicit | fail | implicit is offered - removed under OAuth 2.1\n- Protocol core | password | fail | the password grant is offered - removed under OAuth 2.1\n- Protocol core | authcode | pass | offered\n- Protocol core | refreshgrant | pass | offered\n- Protocol core | clientcreds | pass | offered\n- Protocol core | pkce | warn | Required for public clients only - confidential clients need it too\n- Protocol core | pkceadv | warn | plain, S256 - drop plain\n- Protocol core | matching | fail | Prefix match - the matcher in-the-wild attacks exploited\n- Protocol core | redirects | fail | https://*.hookvane.example/taskmoor/callback (a wildcard); http://reports.clockrill.example/cb (plain http off loopback)\n- Protocol core | querytokens | pass | header or POST body only\n- Token lifecycle | codettl | pass | 10 minutes\n- Token lifecycle | codeonce | pass | single-use\n- Token lifecycle | accessttl | warn | 480 minutes, JWT - it cannot be revoked mid-lifetime\n- Token lifecycle | refresh | pass | Non-rotating, no cap - confidential clients only, whose client authentication sender-constrains redemption\n- Scopes and consent | naming | warn | full_access\n- Scopes and consent | catchall | fail | full_access - one scope that grants everything\n- Scopes and consent | split | warn | write with no read scope: comments\n- Scopes and\n\n[... 2151 more characters cut from this display ...]"
}

Reply (long strings shortened for display - see the cuts below):

{
 "task": "core",
 "summary": "Prefix redirect matching with a Hookvane wildcard callback, eight-hour JWTs and the full_access catch-all let one leaked token or compromised app reach every workspace; switch to exact redirect matching and retire implicit and password [... 7 more characters cut from this display]",
 "grants": [
  {
   "grant": "authorization_code",
   "verdict": "keep",
   "why": "It stays the only way a partner app acts for a workspace member, now with PKCE on every request."
  },
  {
   "grant": "refresh_token",
   "verdict": "keep",
   "why": "Partner apps sync tasks, comments and time entries in the background, so they need to renew access without the member present."
  },
  {
   "grant": "client_credentials",
   "verdict": "keep",
   "why": "Bots that post status updates act as themselves, and this grant mints their app tokens without borrowing a member's identity."
  },
  {
   "grant": "implicit",
   "verdict": "remove",
   "why": "It hands the access token back through the browser redirect, and OAuth 2.1 drops it in favour of the code flow with PKCE."
  },
  {
   "grant": "password",
   "verdict": "remove",
   "why": "It teaches partner apps to collect a member's Taskmoor password, which a compromised app then holds outright."
  }
 ],
 "pkce": {
  "method": "S256",
  "clients": "all",
  "why": "Every Taskmoor client is confidential, yet today they skip PKCE and plain is still accepted, so a stolen code can be redeemed; require S256 on every authorization request and reject plain."
 },
 "redirects": {
  "matching": "exact",
  "loopback_port": false,
  "why": "Prefix matching plus the Hookvane wildcard callback and the plain-http Clockrill reports callback let an attacker steer codes to a host they control, so each app must register its full callback and the server compares it character for [... 11 more characters cut from this display]"
 },
 "tokens": {
  "code_ttl_s": 600,
  "code_single_use": true,
  "access_ttl_min": 60,
  "access_format": "jwt",
  "why": "Codes already pass at ten minutes and single-use, but the 480-minute JWT cannot be revoked mid-lifetime, so cutting it to an hour shrinks how long a leaked token keeps working."
 },
 "refresh": {
  "rung": "nonrotating",
  "reuse_detection": false,
  "grace_s": 0,
  "spa_ttl_h": 0,
  "why": "Every Taskmoor client is confidential, so its client authentication already sender-constrains each refresh redemption, and with a ship date weeks away non-rotating tokens hold while rotation can follow later."
 },
 "granularity": {
  "rung": "resource-action",
  "why": "With the release weeks away, resource:action scopes with a read and write split per resource are the cheapest change for 2400 registered apps, and full_access is deleted with its task and comment grants split across the narrow scopes."
 },
 "scopes": [
  {
   "scope": "tasks:read",
   "replaces": [
    "full_access"
   ],
   "copy": "See the tasks in your workspaces",
   "token": "both",
   "access": "read",
   "tier": "assessment"
  },
  {
   "scope": "tasks:write",
   "replaces": [
    "full_access"
   ],
   "copy": "Create and change tasks in your workspaces",
   "token": "both",
   "access": "write",
   "tier": "assessment"
  },
  {
   "scope": "comments:read",
   "replaces": [
    "full_access"
   ],
   "copy": "See the comments on tasks in your workspaces",
   "token": "both",
   "access": "read",
   "tier": "assessment"
  },
  {
   "scope": "comments:write",
   "replaces": [
    "full_access"
   ],
   "copy": "Post comments and status updates on tasks in your workspaces",
   "token": "both",
   "access": "write",
   "tier": "assessment"
  },
  {
   "scope": "time:read",
   "replaces": [],
   "copy": "See the time you logged",
   "token": "user",
   "access": "read",
   "tier": "identity"
  }
 ],
 "consent": {
  "partial": true,
  "diff": "The server compares the scopes an app requested with the ones the member actually approved and returns the granted set with the token, so the app learns which features to switch off instead of failing on a refused call.",
  "incremental": true,
  "delta_only": true,
  "admin": "Org admins get an approval queue for pending installs, a review of every grant across the organization with one-click revoke, and a separate permission tier for app tokens that act org-wide as the app itself."
 },
 "caveats": [
  "Bind tasks, comments and time scopes to user or app tokens now; changing the binding later forces every member to reauthorize.",
  "Apps holding full_access must re-request only the task, comment and time scopes they use; time:read keeps its own name and identity tier.",
  "The Hookvane and Clockrill apps must re-register exact https callbacks before exact matching ships, or their sign-in breaks.",
  "An hour-long JWT still cannot be revoked mid-lifetime; revoking a compromised app takes effect only as its tokens expire."
 ]
}

Cut from this display only (the saved reply and the check use the full text): summary (241 characters, 234 shown); redirects.why (243 characters, 232 shown).

What step 5 prints for it: nonrotating | resource-action | tasks:read (assessment) | tasks:write (assessment) | comments:read (assessment) | comments:write (assessment) | time:read (identity)

The page's check: kept - nothing to flag

program - A task SaaS retrofitting OAuth in weeks

The input carries the saved core's decisions (R.laneForm fills carry from the core reply), as the page's handoff does.

Input (surface is 1419 characters; the first 1397 are shown; facts is 3462 characters; the first 1396 are shown):

{
 "task": "program",
 "provider": "Platform: Taskmoor\nWhat it does: Project and task management for agencies; partner apps sync tasks, comments and time entries for workspace members, and some run as bots that post status updates.\nMode: retrofit, 2400 registered apps\nClients: confidential server apps\nApps act: Also with their own identity (app tokens, org-wide access)\nData-sensitivity ceiling: Other users' data or bulk data\nWho approves an install: End users, and org admins can gate installs\nSubstrate: An authorization server built from scratch\nPurpose: The foundation of an app ecosystem\nTiming: Must ship within weeks",
 "surface": "Grants offered: authorization_code, implicit, password, refresh_token, client_credentials (read from the discovery document)\nPKCE: Required for public clients only; redirect matching: Prefix match; bearer tokens in query strings: no\nRegistered redirect URIs:\n- https://app.brightsync.example/oauth/callback\n- https://*.hookvane.example/taskmoor/callback\n- http://reports.clockrill.example/cb\nAuthorization code: 10 minutes, single-use yes; access token: 480 minutes, jwt\nRefresh tokens: Non-rotating, no cap; reuse detection: no; grace window: not given; browser refresh lifetime: not given\nScopes (scope | consent copy | token type | whose data | page's tier):\n- tasks:read | See the tasks in your workspaces | token type not given | others | tier assessment\n- tasks:write | Create and change tasks in your workspaces | token type not given | others | tier assessment\n- comments:write | comments:write | token type not given | others | tier assessment\n- full_access | Full access to your account | token type not given | others | tier assessment\n- time:read | See the time you logged | user | own | tier identity\nConsent: partial grants no, server-side diff no, incremental no, re-consent shows only the new scope no, admin consent surface no\nRegistration: Developer portal with a review step; verification: Publisher identity verification; testing lane: no cap; runtime subset check: no; one badge\n\n[... 22 more characters cut from this display ...]",
 "carry": "Refresh: nonrotating\nGranularity: resource-action\nGrants: authorization_code (keep), refresh_token (keep), client_credentials (keep), implicit (remove), password (remove)\nScopes (scope | tier | replaces):\n- tasks:read | assessment | replaces full_access\n- tasks:write | assessment | replaces full_access\n- comments:read | assessment | replaces full_access\n- comments:write | assessment | replaces full_access\n- time:read | identity",
 "facts": "The page's checks (10 fail, 9 warn, 10 pass, 0 n/a):\n- Protocol core | implicit | fail | implicit is offered - removed under OAuth 2.1\n- Protocol core | password | fail | the password grant is offered - removed under OAuth 2.1\n- Protocol core | authcode | pass | offered\n- Protocol core | refreshgrant | pass | offered\n- Protocol core | clientcreds | pass | offered\n- Protocol core | pkce | warn | Required for public clients only - confidential clients need it too\n- Protocol core | pkceadv | warn | plain, S256 - drop plain\n- Protocol core | matching | fail | Prefix match - the matcher in-the-wild attacks exploited\n- Protocol core | redirects | fail | https://*.hookvane.example/taskmoor/callback (a wildcard); http://reports.clockrill.example/cb (plain http off loopback)\n- Protocol core | querytokens | pass | header or POST body only\n- Token lifecycle | codettl | pass | 10 minutes\n- Token lifecycle | codeonce | pass | single-use\n- Token lifecycle | accessttl | warn | 480 minutes, JWT - it cannot be revoked mid-lifetime\n- Token lifecycle | refresh | pass | Non-rotating, no cap - confidential clients only, whose client authentication sender-constrains redemption\n- Scopes and consent | naming | warn | full_access\n- Scopes and consent | catchall | fail | full_access - one scope that grants everything\n- Scopes and consent | split | warn | write with no read scope: comments\n- Scopes and\n\n[... 2066 more characters cut from this display ...]"
}

Reply:

{
 "task": "program",
 "summary": "Taskmoor keeps its reviewed developer portal but adds a third-party assessment for every scope touching other members' work, a capped testing lane, a runtime scope-subset check and three separate trust badges.",
 "registration": {
  "rung": "manual",
  "why": "Every partner app already onboards through the developer portal with a review step, so registration stays manual and the review gains the checks it lacks today.",
  "metadata": [
   "Each redirect URI is a full https callback compared by exact string; wildcards such as the Hookvane one and plain-http hosts such as the Clockrill reports one are refused.",
   "Each requested scope comes with a written justification, and the approved set is stored as the app's ceiling.",
   "Apps declare up front whether each scope is used with member tokens, app tokens or both.",
   "Name, logo and publisher are tied to the verified publisher and changes re-enter review."
  ]
 },
 "tiers": [
  {
   "tier": "identity",
   "scopes": [
    "time:read"
   ],
   "requirement": "Every listed app proves who its publisher is before it appears in the directory or reaches workspace members.",
   "recurrence": "once, re-checked when the publisher or app ownership changes"
  },
  {
   "tier": "assessment",
   "scopes": [
    "tasks:read",
    "tasks:write",
    "comments:read",
    "comments:write"
   ],
   "requirement": "An app that reads or changes other members' tasks or comments, including every app moving off full_access, passes a third-party security assessment before approval.",
   "recurrence": "every 12 months, and again when the app requests a new assessment-tier scope"
  }
 ],
 "testing": {
  "user_cap": 100,
  "token_days": 7,
  "warning": "Members installing an unverified app see a full-screen notice that Taskmoor has not reviewed it, naming the developer and the data it asks for before they can continue."
 },
 "enforcement": "On every authorization and token request, Taskmoor rejects any scope that is not a subset of the app's approved set, instead of relying on the one-time check at review.",
 "badges": "The directory shows three distinct marks, verified publisher, self-attested practices and independently assessed, each with its own date, replacing the single badge that today implies all three.",
 "gates": [
  {
   "area": "protocol",
   "gate": "Implicit and password are gone, PKCE S256 is required of every client, and redirects match by exact string.",
   "test": "Automated requests using implicit, password, a missing or plain PKCE challenge, or a Hookvane subdomain variant of a callback all fail with an error."
  },
  {
   "area": "tokens",
   "gate": "Access JWTs live one hour and non-rotating refresh tokens redeem only with the owning client's authentication.",
   "test": "A token is rejected after 60 minutes, and a refresh token presented with another app's client credentials is refused."
  },
  {
   "area": "consent",
   "gate": "Each scope has its own plain sentence, partial grants are diffed on the server, and org admins can approve installs and review grants.",
   "test": "A test member unticks comments:write, the app receives only the remaining scopes, and an admin sees the install waiting in the approval queue."
  },
  {
   "area": "verification",
   "gate": "Every scope sits in a tier, the runtime subset check is live and the testing lane stops at 100 members.",
   "test": "An app requesting an unapproved scope is refused at authorization, and an unverified app's 101st install attempt is blocked."
  }
 ],
 "metrics": [
  "median app-review turnaround, split by tier",
  "number of apps still requesting full_access",
  "scope requests rejected by the runtime subset check per week",
  "partial grants per week, by scope"
 ],
 "rollout": [
  "Ship exact redirect matching, mandatory S256 PKCE and removal of implicit and password, after notifying the 2400 registered apps and asking Hookvane and Clockrill to re-register their callbacks.",
  "Turn on the runtime subset check against each app's currently approved scopes and cut access JWTs to one hour.",
  "Publish the split scopes with their token bindings and give full_access holders a deadline to request the specific scopes they use.",
  "Launch the three badges, the capped testing lane and the admin approval queue.",
  "Require the third-party assessment for every app holding an assessment-tier scope, with existing apps given a dated window."
 ],
 "caveats": [
  "A ship date weeks away leaves little time for 2400 apps to complete assessments; set an explicit grace window.",
  "Retiring full_access will break apps that never request the split scopes by the deadline."
 ]
}

What step 5 prints for it: identity: 1 scopes | assessment: 4 scopes

The page's check: kept - nothing to flag

core - A platform for AI agents and MCP hosts

The page read it as: a leaked token would spread (3 fail, 1 warn, 22 pass); refresh by rank: rotation; granularity by rank: resource-action; registration by rank: gated-dcr; failing checks: querytokens, grace, registration.

Input (facts is 2932 characters; the first 1399 are shown):

{
 "task": "core",
 "provider": "Platform: Buildquill\nWhat it does: A build and deploy platform whose API is called by AI coding agents and MCP hosts on a developer's behalf, plus a CLI.\nMode: retrofit, 310 registered apps\nClients: confidential server apps, native or CLI apps, AI agents or MCP hosts\nApps act: Only on behalf of a signed-in user\nData-sensitivity ceiling: Only the signed-in user's own data\nWho approves an install: End users only\nSubstrate: An authorization server built from scratch\nPurpose: The foundation of an app ecosystem\nTiming: No hard date",
 "surface": "Grants offered: authorization_code, refresh_token, device_code\nPKCE: Required for every client (S256); redirect matching: Exact string match; bearer tokens in query strings: yes\nRegistered redirect URIs:\n- http://127.0.0.1:8976/callback\n- https://agent.codevesk.example/oauth/buildquill\nAuthorization code: 10 minutes, single-use yes; access token: 60 minutes, opaque\nRefresh tokens: Rotating; reuse detection: yes; grace window: 0 seconds; browser refresh lifetime: not given\nScopes (scope | consent copy | token type | whose data | page's tier):\n- builds:read | See your builds and their logs | user | own | tier identity\n- builds:write | Start and cancel builds | user | own | tier identity\n- deploys:write | Deploy your projects to production | user | own | tier identity\n- secrets:read | Read the secrets in your projects | user | own | tier identity\nConsent: partial grants yes, server-side diff yes, incremental yes, re-consent shows only the new scope yes, admin consent surface no\nRegistration: Open dynamic registration; verification: Publisher identity verification; testing lane: 100 users, test tokens 7 days; runtime subset check: yes; one badge for every signal: no",
 "carry": "",
 "facts": "The page's checks (3 fail, 1 warn, 22 pass, 0 n/a):\n- Protocol core | implicit | pass | not offered\n- Protocol core | password | pass | not offered\n- Protocol core | authcode | pass | offered\n- Protocol core | refreshgrant | pass | offered\n- Protocol core | device | pass | offered\n- Protocol core | pkce | pass | Required for every client (S256)\n- Protocol core | matching | pass | Exact string match\n- Protocol core | redirects | pass | 2 URIs, all exact\n- Protocol core | querytokens | fail | accepted in the query string - they leak into logs, history and Referer\n- Token lifecycle | codettl | pass | 10 minutes\n- Token lifecycle | codeonce | pass | single-use\n- Token lifecycle | accessttl | pass | 60 minutes, opaque\n- Token lifecycle | refresh | pass | Rotating\n- Token lifecycle | reuse | pass | a replayed token revokes the family\n- Token lifecycle | grace | fail | none - multi-tab SPAs and retries read as attacks and log users out\n- Scopes and consent | naming | pass | 4 scopes\n- Scopes and consent | catchall | pass | none\n- Scopes and consent | split | warn | write with no read scope: deploys\n- Scopes and consent | copy | pass | per-scope copy\n- Scopes and consent | partial | pass | partial grants, server-side diff\n- Scopes and consent | incremental | pass | incremental, delta only\n- Registration and verification | registration | fail | Open dynamic registration - self-asserted\n\n[... 1533 more characters cut from this display ...]"
}

Reply:

{
 "task": "core",
 "summary": "Buildquill accepts bearer tokens in query strings, where they leak into logs, history and Referer, and anyone can self-register an agent client; refuse query-string tokens and add a 20-second refresh grace window first.",
 "grants": [
  {
   "grant": "authorization_code",
   "verdict": "keep",
   "why": "Server apps, agents and MCP hosts act only for a signed-in developer, and this flow with PKCE is how that developer approves them."
  },
  {
   "grant": "refresh_token",
   "verdict": "keep",
   "why": "Coding agents run long sessions against builds and deploys, so they renew access through rotating refresh tokens rather than re-prompting the developer."
  },
  {
   "grant": "device_code",
   "verdict": "keep",
   "why": "The Buildquill CLI and headless agents have no browser of their own, so the developer approves them on a second screen."
  }
 ],
 "pkce": {
  "method": "S256",
  "clients": "all",
  "why": "Buildquill already requires S256 for every client, which stays because CLI and agent clients cannot keep a secret and a code caught on the loopback callback must be useless without the verifier."
 },
 "redirects": {
  "matching": "exact",
  "loopback_port": true,
  "why": "Both registered callbacks already match exactly, and only the CLI's loopback callback on 127.0.0.1 may vary its port, so the Codevesk agent callback stays a fixed string."
 },
 "tokens": {
  "code_ttl_s": 600,
  "code_single_use": true,
  "access_ttl_min": 60,
  "access_format": "opaque",
  "why": "Ten-minute single-use codes and hour-long opaque access tokens already pass, and every token is now accepted only in the Authorization header or a POST body, never in a query string."
 },
 "refresh": {
  "rung": "rotation",
  "reuse_detection": true,
  "grace_s": 20,
  "spa_ttl_h": 0,
  "why": "Rotation with family revocation already catches a replayed token, but with no grace window an agent's retry or two parallel tool calls refreshing at once read as theft, so a 20-second window lets the same successor be returned."
 },
 "granularity": {
  "rung": "resource-action",
  "why": "Resource:action scopes with a read and write split are the idiom agent and MCP clients already integrate against cheaply, and only deploys lacks its read half."
 },
 "scopes": [
  {
   "scope": "builds:read",
   "replaces": [],
   "copy": "See your builds and their logs",
   "token": "user",
   "access": "read",
   "tier": "identity"
  },
  {
   "scope": "builds:write",
   "replaces": [],
   "copy": "Start and cancel builds",
   "token": "user",
   "access": "write",
   "tier": "identity"
  },
  {
   "scope": "deploys:read",
   "replaces": [],
   "copy": "See your deployments and their status",
   "token": "user",
   "access": "read",
   "tier": "identity"
  },
  {
   "scope": "deploys:write",
   "replaces": [],
   "copy": "Deploy your projects to production",
   "token": "user",
   "access": "write",
   "tier": "identity"
  },
  {
   "scope": "secrets:read",
   "replaces": [],
   "copy": "Read the secrets in your projects",
   "token": "user",
   "access": "read",
   "tier": "identity"
  }
 ],
 "consent": {
  "partial": true,
  "diff": "The server keeps diffing the approved scopes against the requested ones and returns the granted set, so an agent that was refused production deploys knows to stop at starting builds.",
  "incremental": true,
  "delta_only": true,
  "admin": ""
 },
 "caveats": [
  "Open dynamic registration lets any client claim another app's name; gate it before any agent-facing launch.",
  "Rejecting query-string tokens breaks any of the 310 apps that send them there; give them a deprecation notice.",
  "An agent that only checks deploy status should move to deploys:read instead of holding deploys:write."
 ]
}

What step 5 prints for it: rotation | resource-action | builds:read (identity) | builds:write (identity) | deploys:read (identity) | deploys:write (identity) | secrets:read (identity)

The page's check: kept - nothing to flag

Build the input yourself

Load the engine in node (global.window = global; require("./oakit.js"); require("./oareply.js")), then:

const K = OAuthKit, R = OAuthReply;
const f = K.form(Object.assign(K.blank(), { name: "Acme", grants: "authorization_code\nrefresh_token", pkce: "all", matching: "exact",
  redirects: "https://app.acme.example/cb", scopes: "files:read | See your files | user | own\nfiles:write | Change your files | user | own" }));
// f.rows (every check: area, id, label, status, evidence), f.ref, f.grain, f.reg (the rungs), f.scopes (each with its tier)
// K.matchRedirect(["https://app.acme.example/cb"], uri) - exact match with the loopback port exception; K.discoveryDoc(f)
const b = K.buildInput(f, "core");                           // or "program" (R.laneForm(form, "program", core) fills carry)
const wire = K.toWire(b.input);                              // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "core");
// R.check(reply, b)                                         // {status: kept or flagged, notes}

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://oauth-surface.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":"oauth-surface"}' | 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": "core", "provider": "Platform: Taskmoor\nMode: retrofit, 2400 registered apps\n...", "surface": "Grants offered: authorization_code, implicit, password, ...", "carry": "", "facts": "The page's checks (10 fail, 9 warn, ...):\n- Protocol core | implicit | 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 oauth-surface:<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 core reply carries summary, grants, pkce, redirects, tokens, refresh, granularity, scopes, consent and caveats; a program reply carries summary, registration, tiers, testing, enforcement, badges, gates, metrics, rollout and caveats. The code below prints the gist of either: the two rungs and each scope with its review tier, or each launch gate with its area.

KEY="oauth-surface:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
  J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
  case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json   # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "program" then (.gates[] | "\(.area): \(.gate)") 
       else ("\(.refresh.rung), \(.granularity.rung)", (.scopes[] | "\(.scope) - \(.tier)")) 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: oauth-surface:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json