Install
openclaw skills install auth-md-signupComplete an auth.md USER-CLAIMED signup (OTP flow) against any service that publishes the auth.md protocol. Consent-preserving — the human confirms the OTP; the agent never auto-confirms. Reference implementation targets NoForm (noform.dev).
openclaw skills install auth-md-signupAttributed to NoForm — the reference implementation for the auth.md protocol. Protocol spec: https://github.com/workos/auth.md (MIT)
Teach the agent to complete an auth.md user-claimed signup against any service that publishes the protocol. The human confirms the one-time code; the agent never auto-confirms. That human step IS the consent gate — preserve it.
Use this skill when any of these are true:
HTTP 401 carrying:
WWW-Authenticate: Bearer resource_metadata="<url>"
<service>" and you can resolve an auth.md
file at that service's domain or at a NoForm slug URL.Do NOT use this skill for the agent-verified / ID-JAG flow (that is a
different flow requiring a provider-signed assertion). This skill implements
only the user-claimed (anonymous-register → email-OTP → claim/complete)
flow as mandated by the mission constraints.
When the user says "sign me up on NoForm" or supplies a NoForm slug (<slug>),
the URL structure is:
auth.md file → GET https://noform.dev/a/<slug>/auth.md
PRM → GET https://noform.dev/a/<slug>/.well-known/oauth-protected-resource
AS metadata → GET <authorization_server>/.well-known/oauth-authorization-server
Parse the agent_auth block from the AS metadata to get identity_endpoint
(or register_uri), claim_endpoint (or claim_uri), and revocation_uri.
NoForm is the reference; for any other service follow the same discovery chain
from their domain.
WWW-Authenticate: Bearer resource_metadata="<prm_url>" header,
extract <prm_url> directly (skip to Step 2).https://<domain>/.well-known/oauth-protected-resourcehttps://noform.dev/a/<slug>/.well-known/oauth-protected-resourcecurl -sS "https://<domain>/auth.md"
# or for NoForm:
curl -sS "https://noform.dev/a/<slug>/auth.md"
Parse it as prose context. The authoritative machine-readable state is the PRM and AS metadata — if anything conflicts, the metadata wins.
curl -sS "<prm_url>" | jq .
Extract from response:
resource — the canonical API base URL (used for scoping)resource_name — human-readable service name (show to user for consent)resource_logo_uri — logo URL (show to user if available)authorization_servers[0] — base URL of the Authorization ServerAS_BASE="<authorization_servers[0]>"
curl -sS "${AS_BASE}/.well-known/oauth-authorization-server" | jq .
From the agent_auth block extract and store:
agent_auth.identity_endpoint (may also appear as register_uri)agent_auth.claim_endpoint (may also appear as claim_uri)agent_auth.revocation_uriagent_auth.identity_types_supported — verify anonymous is listed before
proceeding. If it is not, tell the user this service does not support the
user-claimed flow and stop.Before registering, check whether the agent already holds a valid scoped token
for this service (keyed by resource URL). If yes, surface it to the user and
ask whether they want to re-register or skip.
Surface to the user IN-CHANNEL before doing anything:
I'm about to register an account on <resource_name> using the auth.md
user-claimed flow. Here's what will happen:
1. I register anonymously — no account exists yet.
2. I'll ask for your email address.
3. <resource_name> will email you a one-time code.
4. You read the code back to me.
5. I submit the code to bind the account to your email.
6. I store a scoped token for <resource_name> — only for that service.
Your email and primary credentials are never stored or sent anywhere else.
Proceed? (yes / no)
Wait for explicit confirmation before continuing.
curl -sS -X POST "<identity_endpoint>" \
-H "Content-Type: application/json" \
-d '{"type": "anonymous"}'
Expected success response:
{
"credential": "<pre-claim-token>",
"claim_token": "<claim-token>",
"credential_expires": "<iso8601>",
"scopes": ["api.read"]
}
Store claim_token. The credential (if present) is a pre-claim scoped token;
note it but do not hand it to the user — it is a low-scope placeholder.
If the service does not return a pre-claim credential (email-required variant),
that is fine — proceed to the claim step.
Error handling:
anonymous_not_enabled → tell user this service requires identity assertion;
stop.rate_limited → tell user to try again later; stop.To bind this account to you, I need your email address. What email should
I use to register with <resource_name>?
Wait for the user's reply. Never auto-fill the email.
curl -sS -X POST "<claim_endpoint>" \
-H "Content-Type: application/json" \
-d "{\"claim_token\": \"<claim_token>\", \"email\": \"<user_email>\"}"
Expected response (email-dispatch confirmation):
{
"status": "pending",
"message": "Verification email sent to <email>"
}
Some services return a verification_uri here — surface it to the user.
Error handling:
invalid_claim_token → the anonymous registration may have expired; restart
from Step 6.claim_expired → same; restart from Step 6.previously_claimed → tell the user this email is already registered with
this service; stop.rate_limited → tell user to try again later.Tell the user IN-CHANNEL:
📧 A verification email is on its way to <email>.
Open it and look for a 6-digit code (or a "Verify email" link).
When you have it, paste the code here and I'll complete the registration.
If a verification_uri was returned in Step 8, include it:
You can also click this link to verify: <verification_uri>
⚠️ DO NOT PROCEED until the user provides the code. NEVER auto-confirm. NEVER poll the service for code completion. The human completing the claim IS the consent gate — preserve it absolutely.
When the user provides the code:
curl -sS -X POST "<claim_endpoint>/complete" \
-H "Content-Type: application/json" \
-d "{\"claim_token\": \"<claim_token>\", \"otp\": \"<otp>\"}"
Expected success response:
{
"credential": "<active-scoped-token>",
"credential_expires": "<iso8601 or null>",
"scopes": ["api.read", "api.write"]
}
Error handling:
otp_invalid → tell user the code was wrong; ask them to check and try again
(allow up to 3 attempts before stopping).otp_expired → tell user the code expired; offer to restart from Step 8
(re-send OTP).claim_expired → restart from Step 6.previously_claimed → account already claimed; stop.Store the returned credential scoped to this service:
Key format: auth_md_token:<resource_url>
Value: { "credential": "...", "scopes": [...], "expires": "...", "service": "<resource_name>", "email": "<user_email>", "registered_at": "<iso8601>" }
If AUTH_MD_TOKEN_STORE env var is set, append to that JSON file.
Otherwise, hold in session memory only.
Never write the user's primary password, primary API keys, or other service credentials anywhere.
Tell the user:
✅ Registered successfully with <resource_name>!
Scopes granted: <scopes>
Bound to: <email>
Expires: <credential_expires or "never">
Your token is stored scoped to <resource_name> only. I'll use it
automatically for requests to this service.
When the user asks to revoke a token for a service:
curl -sS -X POST "<revocation_uri>" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <credential>" \
-d "{\"token\": \"<credential>\"}"
On success, delete the stored token for that resource key and confirm to the
user. On a 401 from a previously-working credential, the token was already
revoked externally — delete it locally and notify the user.
resource URL as key). One service cannot
access another service's token.| Step | HTTP call |
|---|---|
| 1 | GET <domain>/auth.md |
| 2 | GET <prm_url> (from WWW-Authenticate or /.well-known/oauth-protected-resource) |
| 3 | GET <auth_server>/.well-known/oauth-authorization-server |
| 6 | POST <identity_endpoint> {"type":"anonymous"} |
| 8 | POST <claim_endpoint> {"claim_token":"…","email":"…"} |
| 10 | POST <claim_endpoint>/complete {"claim_token":"…","otp":"…"} |
| revoke | POST <revocation_uri> with Bearer + {"token":"…"} |
For NoForm (<slug>):
https://noform.dev/a/<slug>/.well-known/oauth-protected-resourcehttps://noform.dev/a/<slug>/.well-known/oauth-authorization-server
(or the authorization_servers[0] URL from PRM)| Code | Endpoint | Action |
|---|---|---|
anonymous_not_enabled | register | Stop; tell user |
identity_assertion_not_enabled | register | Stop; tell user |
rate_limited | any | Stop; ask user to retry later |
invalid_claim_token | claim | Restart from Step 6 |
claim_expired | claim / claim/complete | Restart from Step 6 |
previously_claimed | claim / claim/complete | Stop; tell user |
otp_invalid | claim/complete | Ask user to retry (max 3x) |
otp_expired | claim/complete | Offer to re-send OTP (Step 8) |
credential_expired | API call | Delete stored token; restart from Step 6 |
unsupported_credential_type | register | Stop; report unsupported |
noform.devapi.example.com" → discover from that domainapi.example.com — get me credentials" → use the
WWW-Authenticate header to start discoveryThis skill implements only the user-claimed flow from the auth.md protocol. The agent-verified (ID-JAG) flow is intentionally out of scope. Attributed to NoForm. Protocol by WorkOS.