← Auth Desk / API

Auth Desk API

Everything the web app does over the model is one HTTP call. Base URL https://api.skillsafe.ai/v1/app-api. Every request carries Authorization: Bearer <token> and every response uses the same envelope.

The response envelope

Success is {"ok": true, "data": {...}}. Failure is {"ok": false, "error": {"code": "...", "message": "...", "details": {...}}}. Always branch on ok, never on the HTTP status alone.

CodeHTTPWhat it means here
UNAUTHORIZED401Missing, malformed or expired token. Mint a new one on the token page.
PAYMENT_REQUIRED402Balance below min_credits for this lane. Call /estimate first and top up.
VALIDATION_ERROR400The input object is the wrong shape — usually a missing config or an unknown task.
RATE_LIMITED429Back off and retry. Do not tight-loop.
NOT_FOUND404Wrong job id, or a job that belongs to another subject.
INTERNAL500Retry once with the same idempotency key.

The task field comes first

Auth Desk is one app with four lanes over one work object. Every request must set task; it selects the lane, the prompt section, the output body shape and the price. If task is missing the model picks the closest lane and reports lane_inferred: true — usable, but never what you want from a script.

taskWhat that lane returns
auditReview the whole configuration and return an ordered hardening plan plus the corrected auth.ts.
passwordBuild out the credential surface: policy, all five flows, and the transactional emails.
twofactorMount and wire the twoFactor plugin: methods, enrolment, recovery, client calls.
organizationDesign roles, resources, a complete access matrix and the invitation flow.

Input fields

Taken from readForm() in app.js — this is exactly what the web app sends.

FieldTypeRequiredNotes
taskstringyesOne of audit, password, twofactor, organization.
configstringyesThe pasted setup. Separate multiple files with a // file: name.ts line. Clipped from the middle at 48,000 characters, both ends kept.
notesstringnoFree text about the product. The organization lane leans on it heavily; send it empty otherwise.
frameworkstringnoThe framework you believe it is, or "unknown".
prescanobjectnoThe browser prescan. Omit it and the model simply has fewer facts — but then coverage_check comes back empty, because there are no flags to reconcile.
clip_notestringnoSend it when you clipped config yourself, so the model writes around the gap.
retry_notestringnoSend it on a second attempt when the first reply did not parse, naming what was wrong. The web app sets it automatically and reuses an idempotency key with the attempt counter bumped, so the retry is a new run rather than a duplicate charge.
redaction_notestringnoSend it when you stripped a literal secret out of config before sending. The web app always does: the auth secret, any OAuth clientSecret and any password inside a database URL are replaced with a marker, and the note tells the model the values are compromised and must not be reconstructed.

Step 1 — get a token

Open the token page, reveal your token and copy the shell export. It is the same token the web app holds in this browser, so a script and the page share one identity, one balance and one history. Keep it out of source control — export it as an environment variable and read it from there, the way every sample below does.

Step 2 — confirm the session and the balance

GET /me is free. It tells you whether the token is a personal or a guest subject and how many credits it can spend.

curl -sS "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer $AUTH_DESK_TOKEN"

Step 3 — estimate before you spend

POST /estimate is free and charges nothing. It returns model, model_alias, markup_bps, hold_credits and min_credits. The hold differs per lane, so estimate the lane you are about to run — never reuse another lane’s number.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $AUTH_DESK_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "task": "audit",
  "config": "import { betterAuth } from \"better-auth\";\nimport { drizzleAdapter } from \"better-auth/adapters/drizzle\";\n\nexport const auth = betterAuth({\n  database: drizzleAdapter(db, { provider: \"pg\" }),\n  emailAndPassword: { enabled: true, minPasswordLength: 6 },\n  plugins: [twoFactor()]\n});",
  "notes": "",
  "framework": "unknown"
}'

Step 4 — run, then poll

POST /run returns {"job_id": "..."} immediately. Poll GET /jobs/{job_id} until status is terminal. Always send an Idempotency-Key header derived from the lane plus a hash of the input plus an attempt counter: a retried request with the same key returns the original job instead of billing you twice.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $AUTH_DESK_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: auth-desk:password:9f3a2c:a1" \
  -d '{
  "task": "password",
  "config": "import { betterAuth } from \"better-auth\";\nimport { drizzleAdapter } from \"better-auth/adapters/drizzle\";\n\nexport const auth = betterAuth({\n  database: drizzleAdapter(db, { provider: \"pg\" }),\n  emailAndPassword: { enabled: true, minPasswordLength: 6 },\n  plugins: [twoFactor()]\n});",
  "notes": "Consumer product, open sign-up, mail through Resend.",
  "framework": "unknown"
}'

Then poll the job:

curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID" \
  -H "Authorization: Bearer $AUTH_DESK_TOKEN"

Step 5 — stream instead

POST /run-stream is the same call over Server-Sent Events. The web app uses it so the staged progress card can advance on real signals in the delta stream. The same Idempotency-Key rule applies.

curl -sS -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $AUTH_DESK_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: auth-desk:password:9f3a2c:a1" \
  -d '{
  "task": "password",
  "config": "import { betterAuth } from \"better-auth\";\nimport { drizzleAdapter } from \"better-auth/adapters/drizzle\";\n\nexport const auth = betterAuth({\n  database: drizzleAdapter(db, { provider: \"pg\" }),\n  emailAndPassword: { enabled: true, minPasswordLength: 6 },\n  plugins: [twoFactor()]\n});",
  "notes": "Consumer product, open sign-up, mail through Resend.",
  "framework": "unknown"
}'

Step 6 — the other two lanes

Every worked example above uses task: "audit" or task: "password", so here are the remaining two end to end. Nothing about the call changes — same endpoint, same envelope, same Idempotency-Key rule — only task, the price, and the shape of body in the reply.

task: "twofactor"

Returns body.methods, body.enrolment, body.recovery and body.client_calls. The artifact is usually a corrected src/lib/auth-client.ts rather than auth.ts, so merge it into your paste by file name instead of overwriting the server config.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $AUTH_DESK_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: auth-desk:twofactor:4c81ab:a1" \
  -d '{
  "task": "twofactor",
  "config": "import { betterAuth } from \"better-auth\";\nimport { prismaAdapter } from \"better-auth/adapters/prisma\";\nimport { twoFactor } from \"better-auth/plugins\";\n\nexport const auth = betterAuth({\n  secret: env.BETTER_AUTH_SECRET,\n  database: prismaAdapter(prisma, { provider: \"postgresql\" }),\n  emailAndPassword: { enabled: true, requireEmailVerification: true },\n  plugins: [twoFactor()]\n});\n\n// file: src/lib/auth-client.ts\nimport { createAuthClient } from \"better-auth/react\";\n\nexport const authClient = createAuthClient({ plugins: [] });",
  "notes": "Enterprise customers want MFA. Transactional email only, no SMS provider.",
  "framework": "Next.js"
}'

task: "organization"

Returns body.roles, body.resources, body.matrix, body.invitation_flow and body.gaps. This is the one lane where notes genuinely changes the answer: it is designing an access-control model, not reading one, so say who the tenants are and which role names you already use.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $AUTH_DESK_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: auth-desk:organization:7d20fe:a1" \
  -d '{
  "task": "organization",
  "config": "import { betterAuth } from \"better-auth\";\nimport { prismaAdapter } from \"better-auth/adapters/prisma\";\nimport { organization } from \"better-auth/plugins\";\n\nexport const auth = betterAuth({\n  secret: env.BETTER_AUTH_SECRET,\n  baseURL: \"https://console.northwind.dev\",\n  database: prismaAdapter(prisma, { provider: \"postgresql\" }),\n  emailAndPassword: { enabled: true },\n  plugins: [organization()]\n});\n\n// file: src/lib/auth-client.ts\nimport { createAuthClient } from \"better-auth/react\";\nimport { organizationClient } from \"better-auth/client/plugins\";\n\nexport const authClient = createAuthClient({ plugins: [organizationClient()] });",
  "notes": "B2B SaaS. Customers are companies with projects and one monthly invoice. We already say workspace owner, admin and engineer internally; support staff need read-only access to a customer's projects without being members; billing is the owner's alone.",
  "framework": "Next.js"
}'

The output contract

Every lane returns one JSON object with the same envelope. This is the shape normalize() in app.js enforces — a reply that does not match it is retried once with a reformat instruction, then shown raw.

{
  "lane": "audit | password | twofactor | organization",
  "lane_inferred": false,
  "title": "string",
  "posture": "ship-ready | harden-first | not-production-safe",
  "verdict": "one sentence",
  "stack": "string",
  "adapter": "string",
  "summary": "string",
  "assumptions": ["string"],
  "open_questions": ["string"],
  "findings": [
    {
      "id": "AD-001",
      "title": "string",
      "severity": "critical | high | medium | low",
      "area": "secrets | database | session | cookies | email | oauth | plugins | rate-limit | rbac | wiring",
      "file": "string",
      "line": 0,
      "evidence": "verbatim lines from the paste",
      "why": "string",
      "fix": "string",
      "fix_code": "TypeScript"
    }
  ],
  "coverage_check": [
    {"flag_id": "BA-NO-RATELIMIT", "status": "confirmed | set-aside | superseded",
     "finding_id": "AD-003", "note": "string"}
  ],
  "artifact": {"kind": "none | typescript | markdown", "filename": "auth.ts", "content": "string"},
  "next_lane": {"lane": "twofactor", "reason": "string"},
  "body": { }
}

Per-lane body

task: "audit"

"body": {
  "hardening_plan": [
    {"title": "string", "why": "string", "risk": "critical|high|medium|low",
     "effort": "minutes|an hour|a day", "code": "TypeScript"}
  ],
  "parity_notes": [{"plugin": "organization", "client_plugin": "organizationClient", "note": "string"}],
  "residual_risks": ["string"]
}

task: "password"

"body": {
  "policy": {
    "min_length": "8", "min_length_why": "string",
    "max_length": "128", "max_length_why": "string",
    "require_verification": "true", "verification_why": "string",
    "auto_sign_in": "false", "auto_sign_in_why": "string",
    "hashing": "library default (scrypt)", "hashing_why": "string"
  },
  "flows": [
    {"name": "sign-up", "status": "complete|partial|missing",
     "steps": ["string"], "gaps": ["string"], "code": "TypeScript"}
  ],
  "emails": [{"name": "Verify your email", "subject": "string", "body": "string"}]
}

All five flows always appear: sign-up, verify email, sign-in, reset password, change password.
Every policy value is a STRING, so that "library default" is expressible.

task: "twofactor"

"body": {
  "methods": [{"method": "TOTP", "status": "present|weak|missing|n-a", "detail": "string"}],
  "enrolment": [{"step": "string", "description": "string", "code": "TypeScript"}],
  "recovery": {
    "backup_codes": "string", "backup_codes_note": "string",
    "trusted_devices": "string", "trusted_devices_note": "string",
    "note": "what a user does when they lose the device"
  },
  "client_calls": [{"call": "authClient.twoFactor.enable({ password })", "purpose": "string"}]
}

task: "organization"

"body": {
  "roles": [{"role": "owner", "inherits": "admin", "permissions": ["project:delete"]}],
  "resources": [{"resource": "project", "actions": ["create", "read", "update", "delete"]}],
  "matrix": [{"role": "member", "resource": "project", "actions": ["read"]}],
  "invitation_flow": [{"step": "string", "description": "string", "code": "TypeScript"}],
  "gaps": ["string"]
}

The matrix is COMPLETE: one row per (role, resource) pair. A pair with no access carries an
empty actions array rather than being omitted.

The reconciliation rule

coverage_check carries exactly one entry per prescan flag id you sent and no entries for ids you did not send. A flag with no entry is rendered to the user as not accounted for. If you are scripting Auth Desk, this is the assertion worth writing: the set of flag_id values in the reply must equal the set of id values in prescan.flags, and every confirmed entry must name a finding_id that exists in findings.


Auth Desk is a derived work built on four @better-auth/skills — best-practices, emailandpassword, twofactor and organization. It is not affiliated with, endorsed by, or operated by the Better Auth project.

Back to Auth Desk · Manage your token · llms.txt