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.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The 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. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes, chosen by task, in the order the work runs - the server, then the apps:
core- the provider core: grants kept, added and removed, PKCE and redirects, code and access token lifetimes, the refresh posture, the scope granularity, the new scope table with consent sentences, token types and review tiers, and consent.program- the registration and verification program: the registration model, the review tiers, the testing lane, runtime enforcement, trust badges, launch gates, metrics and the rollout.
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
| Field | Required | What |
|---|---|---|
task | yes | core or program. If it is missing or neither, do core and set "task": "core". |
provider | yes | the 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. |
surface | yes | the 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. |
carry | send it (empty for core) | program task only: the core's decisions, when the core task ran first; may be empty. |
facts | yes | the 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_note | no | optional; 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:
- The profile is OAuth 2.1. RFC 9700 (the OAuth security best current practice) is ratified; the consolidated OAuth 2.1 document is still a draft - never call it a published RFC.
- Protocol core: authorization_code with PKCE (S256, mandatory for every client, public and confidential alike), client_credentials, refresh_token and device authorization for CLIs and headless clients. No implicit grant and no password grant - both are removed. Redirect URIs match by exact string, the single exception being the port of a native app's loopback redirect. Bearer tokens never in a query string. Authorization codes single-use, at most 10 minutes. Access tokens about an hour (60 to 90 minutes is the convergence); keep JWT access tokens short because they cannot be revoked mid-lifetime.
- Refresh posture, ranked: rotation with token-family reuse detection by default (a consumed token presented again revokes the whole family) with a 10 to 30 second reuse grace window from day one; DPoP when tokens guard financial or health data or long-lived agent tokens widen the blast radius; non-rotating survives only for confidential clients, or capped per account and client. A non-rotating, uncapped refresh token for a public client is banned. Browser refresh tokens live at most 24 hours.
- Scopes, ranked: resource:action names with a read/write split and read-only defaults by default; fine-grained per-resource permissions when org admins gate installs, a compliance regime is in scope or scopes reach org-wide data - keeping the coarse tier alive through the migration. A broad catch-all scope is deleted, not ranked. Additive only: never repurpose a scope, add a new one. Bind every scope to a token type (user or app) on day one when apps also act as themselves. Accept more scopes where resource types genuinely differ.
- Consent: one plain-language sentence per scope - never grouped, never the raw scope string. Partial grants, with granted-versus-requested diffed on the server, always. Incremental authorization, and a re-consent screen showing only the net-new scope. Where org admins gate installs, an admin surface: an approval queue, org-wide grant review, an app-identity permission tier. No consent conversion figure is a measurement - never cite one.
- Registration, ranked: manual developer-portal registration with a review step by default; dynamic registration behind an initial access token, rate-limited, with all metadata treated as untrusted, only for an AI-agent or MCP ecosystem whose clients cannot pre-register. Open dynamic registration is deleted - self-asserted metadata can impersonate any app.
- Verification, scaled to sensitivity: publisher identity verification for every listed app; self-attestation as the middle rung; a third-party security assessment the moment a scope reaches other users' data, renewed every 12 months. Keep identity, attestation and audit visually distinct - one badge for all three misleads. Unverified apps get a testing lane with a hard cap (100 users at most) and short test tokens (7 days at most). Reject at runtime any scope request that is not a subset of what was approved.
- Gates, until every one passes: protocol (no implicit or password, PKCE rejected if absent, exact redirects - by test), tokens (a replayed refresh token revokes the family; a concurrent refresh does not log a user out), consent (per-scope copy; a partial-grant test exercises the diff), verification (every scope in a tier; the runtime subset check and the testing-lane cap enforced).
House rules
- Use only the input. Never invent a customer, an app count, an incident, a vendor's policy beyond the rules above, or a claim about the platform.
- The page's checks are settled: never call a failed check fine or a passed one broken.
- No URL at all - endpoints are paths, redirect URIs are named by the app they belong to.
- Write in the language of the input; keep the JSON keys, grant names, scope names and header names in English.
- 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
core
Write the provider core.
summary- one sentence, at most 40 words: what lets a leaked token or a compromised app spread today, and the first fix.grants- one object per grant:grant(authorization_code, client_credentials, refresh_token, device_code, implicit, password, ...),verdict(keepfor a grant offered today that stays,addfor one the design adds,removefor one offered today that goes),why(one sentence). Every grant offered today appears; implicit and password, when offered, are removed.pkce-method(S256),clients(all),why(one sentence).redirects-matching(exact),loopback_port(true when native apps redirect to loopback),why(one sentence).tokens-code_ttl_s(at most 600),code_single_use(true),access_ttl_min(about 60, at most 90),access_format(opaqueorjwt),why(one sentence).refresh-rung(the facts' rung:rotation,dpopornonrotating),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).granularity-rung(the facts' rung:resource-actionorfine),why(one sentence).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,apporboth),access(readorwrite),tier(identity,attestationorassessment- a scope reaching other users' or regulated data isassessment). 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.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 "").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.
summary- one sentence, at most 40 words.registration-rung(the facts' rung:manualorgated-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).tiers- one object per review tier in use:tier(identity,attestationorassessment),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).testing-user_cap(at most 100),token_days(at most 7),warning(one sentence: what an unverified app's users see).enforcement- one sentence: the runtime check that rejects a scope request outside the approved set.badges- one sentence: how identity, attestation and audit are shown as distinct signals.gates- four or more objects:area(protocol,tokens,consentorverification- all four covered),gate(one sentence),test(one sentence: how it is proven).metrics- two to four strings: the trends to baseline from launch.rollout- three to six strings, in order: how the program and the core reach the apps already registered.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"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://oauth-surface.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"]
// oauth-surface.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://oauth-surface.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://oauth-surface.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
}
// OAuth Surface.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 OAuth Surface {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://oauth-surface.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://oauth-surface.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://oauth-surface.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://oauth-surface.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":"oauth-surface"}' | 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": "oauth-surface"})["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: "oauth-surface" })).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": "oauth-surface"}, 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", "oauth-surface"), 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: "oauth-surface" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "oauth-surface"])["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 = "oauth-surface" }))!["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": "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}'
inp = json.load(open("input.json")) # {"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
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: "core" or "program", provider, surface, 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 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
import hashlib, time
key = "oauth-surface:" + 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"] == "program":
for x in reply["gates"]:
print(x["area"], x["gate"])
else:
print(reply["refresh"]["rung"], reply["granularity"]["rung"])
for s in reply["scopes"]:
print(s["scope"], s["tier"])
import { createHash } from "node:crypto";
const key = `oauth-surface:${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 === "program") for (const x of reply.gates) console.log(x.area, x.gate);
else { console.log(reply.refresh.rung, reply.granularity.rung); for (const s of reply.scopes) console.log(s.scope, s.tier); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "oauth-surface:" + 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 := "scopes"
if reply["task"] == "program" {
key = "gates"
}
ls, _ := reply[key].([]any)
for _, x := range ls {
xx := x.(map[string]any)
fmt.Println(xx["scope"], xx["tier"], xx["area"], xx["gate"])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "oauth-surface:" + 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("program")) {
for (JsonNode x : reply.path("gates")) System.out.println(x.path("area").asText() + " " + x.path("gate").asText());
} else {
for (JsonNode x : reply.path("scopes")) System.out.println(x.path("scope").asText() + " " + x.path("tier").asText());
}
require "digest"
key = "oauth-surface:#{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"] == "program"
reply["gates"].each { |x| puts "#{x["area"]} #{x["gate"]}" }
else
reply["scopes"].each { |x| puts "#{x["scope"]} #{x["tier"]}" }
end
$key = "oauth-surface:" . $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"] === "program") { foreach ($reply["gates"] as $x) echo $x["area"], " ", $x["gate"], "\n"; }
else { foreach ($reply["scopes"] as $x) echo $x["scope"], " ", $x["tier"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"oauth-surface:{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 == "program") { foreach (var x in reply["gates"]!.AsArray()) Console.WriteLine($"{x!["area"]} {x["gate"]}"); }
else { foreach (var x in reply["scopes"]!.AsArray()) Console.WriteLine($"{x!["scope"]} {x["tier"]}"); }
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
rev = json.load(open("input.json")) # the same run input as input.json: {"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
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "oauth-surface:" + 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": `oauth-surface:${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", "oauth-surface:"+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", "oauth-surface:" + 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" => "oauth-surface:#{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: oauth-surface:" . 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", $"oauth-surface:{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);