# MicroSaaS Platform — agent manual

This is `/docs/llms.txt`. If you are an LLM agent working with this
platform, read this file end-to-end before doing anything else. Everything
below is stable per major API version.

## Authentication

- Header: `Authorization: token <api_key>:<api_secret>`
- API Credential tokens (minted from the dashboard) also need
  `X-Auth-Source: API Credential` on every request.
- Session cookies also work if you're driven by a browser session.
- Every method call runs under the identity that owns the token: DocPerm,
  Layer 2 User Permission scoping and Layer 3 staff-only rules all apply
  to you.

## The Golden Path — signup → live SaaS in five calls

1. `POST /api/method/platform_control.api.micro_saas.create_micro_saas`
   with `{account, slug, end_user_type?, isolation_tier?, region?}`.
   Returns `{micro_saas, provisioning_job, status}`.
2. Poll `POST /api/method/platform_control.api.micro_saas.get_provisioning_status`
   with `{name: <slug>}` until `status == "Active"`.
3. `POST /api/method/platform_control.credentials.reveal` with
   `{name: <slug>}`. Returns `{site_name, desk_hostname, app_hostname,
   admin_user, admin_password, revealed_at}`.
   **This is a ONE-TIME response.** Store the password immediately; the
   next call to `reveal` returns `409 credentials_already_revealed`.
4. If you lost the password (or a scheduled rotation is due):
   `POST /api/method/platform_control.credentials.rotate` with
   `{name: <slug>}` — mints a fresh password AND returns the same reveal
   payload in one hop.
5. From here on the site is a standard XCORE tenant site. Log in as `Administrator`
   with the revealed password, or start using tenant-facing endpoints.

To BUILD the tenant's product (DocTypes + a single-page app) on our framework —
not just provision the site — read "Building the tenant SPA on our framework"
below: the `ctx` runtime, the manifest, and the author → install → iterate loop.

## Endpoints index

Every `/v1/*` endpoint has a whitelisted method shape:
`/api/method/platform_control.<module>.<function>`. This file lists the
verbs; the full request/response spec is at
`/api/method/platform_control.api.docs.get_openapi_json` (also served at
`/v1/openapi.json`).

- `micro_saas.list_micro_saas(account?, limit?)` — GET, scoped to caller.
- `micro_saas.get_micro_saas(name)` — GET one row.
- `micro_saas.create_micro_saas(account, slug, ...)` — POST, triggers
  provisioning.
- `micro_saas.get_provisioning_status(name)` — GET status + latest job.
- `credentials.reveal(name)` — POST, one-time.
- `credentials.rotate(name)` — POST, mints fresh + reveals.
- `api.credentials.issue(label, micro_saas?, scope_preset?, expires_at?)` /
  `.reveal(name)` / `.rotate(name)` / `.revoke(name)` /
  `.list_credentials(micro_saas?)` — agent API tokens (distinct from the
  admin-password `credentials.*` above). `reveal` is one-time; `rotate`
  mints + reveals in one hop; `revoke` is irreversible.

### Site lifecycle (§16 backups + delete/restore, all T3)

- `api.lifecycle.take_backup(name, kind?)` — POST, kind ∈
  manual/scheduled/pre_delete/pre_restore. Returns a Site Backup id.
- `api.lifecycle.list_backups(name, limit?)` — GET.
- `api.lifecycle.archive(name)` — POST, site stays on disk but refuses
  traffic (410). Fully reversible.
- `api.lifecycle.unarchive(name)` — POST.
- `api.lifecycle.delete(name, confirm=<slug>, approval_ok?)` — POST.
  Auto-takes a pre_delete backup, then drops the site. Row kept; slug
  retained. `confirm` must equal `slug` or 400. Token callers need an
  Approved `approval_ok` (action "lifecycle.delete") — see Blast-radius.
- `api.lifecycle.restore(name, backup?, approval_ok?)` — POST. Takes a
  safety backup of the current site (if any), recreates it if Deleted,
  restores DB from the chosen backup. Defaults to the most recent Done
  backup. Token callers need `approval_ok` (action "lifecycle.restore").
- `api.lifecycle.lifecycle_summary(name)` — GET, one-shot state.

### Frontend files (§11 Surface A, tenant-side)

Layered: writes land in DRAFT, `publish()` promotes to LIVE, snapshots
are the rollback path. Called on the TENANT hostname
(`<slug>.<domain>`), not on control.

- `microsaas_desk_theme.files_api.tree(path?, layer=draft)` — GET.
- `microsaas_desk_theme.files_api.read(path, layer=draft)` — GET.
- `microsaas_desk_theme.files_api.write(path, content, if_match_sha?)`
  — POST. Draft layer only. `.py` refused with
  `native_python_requires_container`.
- `microsaas_desk_theme.files_api.upload(path, content_base64)` —
  POST. Binary path (15 MiB cap). Accepts `data:...;base64,...` URLs.
- `microsaas_desk_theme.files_api.delete(path)` — POST, draft only.
- `microsaas_desk_theme.files_api.publish(comment?)` — POST. Snapshot
  current live + rsync draft to live.
- `microsaas_desk_theme.files_api.list_snapshots()` — GET.
- `microsaas_desk_theme.files_api.rollback(snapshot_id)` — POST.
  Takes a safety snapshot first.
- `microsaas_desk_theme.files_api.revert_draft()` — POST. Reset draft
  to live (previous draft saved as a snapshot).
- `microsaas_desk_theme.files_api.status()` — GET. `{draft_file_count,
  live_file_count, snapshot_count, added, modified, removed,
  has_changes, last_snapshot}` — one shot for the UI + agent.

Preview a draft in the browser: request the app hostname with
`?_preview=draft`. Requires a session with a developer role.

**Custom (site-local) SPA apps — connect your files to the shell.**
`apps.json` and `apps/` are marketplace-managed (publish preserves them),
so write YOUR module under `custom/<slug>/` and register it:
- `microsaas_desk_theme.custom_apps.register_app(slug, name?,
  entry="main.js", styles?, routes?, roles?)` — POST, after publish;
  merges a `custom: true` entry (asset_root `custom/<slug>/`) into
  apps.json. The shell loads it like any marketplace app; `roles` gates
  its sidebar visibility. Idempotent upsert.
- `microsaas_desk_theme.custom_apps.unregister_app(slug)` /
  `.list_custom_apps()` — POST / GET.

**Every tenant also serves its own agent manual** at
`http://<slug>.<domain>/docs/llms.txt` — a tenant key + that one URL is
enough to build the whole product (the Build-with-AI Desk page hands
owners exactly that pair). Tenant schema evolution beyond create:
`schema_api.update_field(doctype, fieldname, changes)`,
`.remove_field(doctype, fieldname)`, `.set_permissions(doctype,
permissions)`, `.delete_doctype(name, confirm=<name>)` — custom
DocTypes only, same Schema Manager gate.

### Usage + quotas (§15.1)

- `api.usage.list_usage(micro_saas, metric?, limit=30)` — GET.
- `api.usage.check_micro_saas_quota` fires implicitly on
  `create_micro_saas`; refusal is `402 plan_quota_exceeded`.

### Marketplace (§13)

- `api.marketplace.list_items(status='Approved', kind?, limit=50)`
  — GET.
- `api.marketplace.get_item(slug)` — GET, returns item + versions[].
- `api.marketplace.lint_manifest(manifest)` — POST. Preflight-validate a
  manifest's SHAPE (no tenant needed): `{ok, errors[], warnings[]}`. Call this
  BEFORE creating a version — it catches unsupported fieldtypes, a Link without
  `options`, a reserved role, snake_case fieldname violations, and the big
  multi-tenant-safety one: an end-user role with `read` but no `if_owner`.
- `api.marketplace.check_compatibility(item, version, micro_saas)` —
  GET. Dry-run against a TENANT: `{ok, tier_gate, collisions[], warnings[]}`.
  Call BEFORE `install` so you never hit an unexpected 409.
- `api.marketplace.install(item, version, micro_saas)` — POST. Full
  install: validates (container-tier gate for `native` items, Approved-only,
  duplicate install, `depends_on`, shell exclusivity, DocType/custom-field
  collision matrix), then ships the payload to the tenant — creates the
  DocTypes/roles, lands the frontend bundle, regenerates `apps.json` — and
  records the `Marketplace Install` row.
- `api.marketplace.upgrade(install, to_version)` — POST. In-place,
  **data-preserving** upgrade of an installed app: additively applies the new
  version's schema (new DocTypes/fields/roles — never a drop, so every row
  survives) and swaps the frontend bundle. Atomic — a pre-upgrade snapshot +
  DB rollback restore the prior version on any failure.
- `api.marketplace.uninstall(install, force=0)` — POST. Refuses with a
  409 (`tenant_data_present`) when the app's DocTypes hold records unless
  `force=1`. Back the data up first: on the tenant,
  `microsaas_desk_theme.app_backup.app_data_summary(install_id)` (GET —
  which tables, how many rows) and
  `microsaas_desk_theme.app_backup.download_app_backup(install_id)` (GET —
  full-record JSON file, child tables included). Uninstall removes the
  app's DocTypes, frontend files and apps.json entry; the underlying table
  rows are retained orphaned and re-attach on a reinstall of the same
  app, but treat that as an implementation detail, not a contract — the
  backup file is the contract. The tenant Desk marketplace page wraps all
  of this in a backup-offering uninstall dialog.
- `api.marketplace.list_installs(micro_saas)` — GET.

Available first-party items: `base` (the Base Shell — the SPA host),
`notes`, `projects`, and `saas-billing-kit`.

## Building the tenant SPA on our framework

Provisioning gives you an empty site; this section is how you turn it into a
product. The end-user product is a single-page app on the tenant's APP hostname
(`<slug>-app.<domain>`) backed by DocTypes — a custom SPA in front of the
platform data layer, NOT the admin Desk. A product = DocTypes (your data, one REST resource each at
`/api/resource/<DocType>`) + a frontend bundle (an ES module plugged into the
Base Shell).

### Shell vs module apps
- The **Base Shell** (exactly one per site) serves `index.html` and exposes the
  runtime as `window.MicroSaaS` (aka `ctx`): login/logout, sidebar, routing, a
  dashboard, global search, and a themed UI toolkit.
- A **module app** (many per site) ships `main.js` (+ optional `main.css`) plus
  its DocTypes/roles. `main.js` exports `register(ctx, app)`, which the shell
  calls at load; every route it registers is grouped under the app's name.

    export function register(ctx, app) {
      const page = ctx.ui.crud({ doctype: "Task", title: "Tasks", singular: "task",
        columns: [{ key: "title", label: "Title" }],
        fields:  [{ name: "title", label: "Title", type: "text", required: true }] });
      ctx.route("/tasks", { label: "Tasks", icon: "grid", mount: page.mount });
    }

### The runtime — `ctx` (== window.MicroSaaS)
- Identity: `ctx.user`, `ctx.fullName`, `ctx.roles`, `ctx.hasRole(r)`,
  `ctx.hasAnyRole([...])`, `ctx.logout()`.
- Data over `/api/resource` (all return Promises; reject with a clean
  `Error.message` safe to show in a toast):
  `ctx.api.list(dt, {fields, filters, orderBy, limit, start})`,
  `.get(dt, name)`, `.create(dt, doc)`, `.update(dt, name, patch)`,
  `.remove(dt, name)`, `.count(dt, filters)`,
  `.call(method, args)` (POST a whitelisted method),
  `.method(method, args)` (GET a whitelisted read-only method).
  Filters use the platform's standard shape: `[[field, operator, value], ...]`.
- Routing: `ctx.route(path, spec)`, `ctx.navigate(path)`, `ctx.back()`,
  `ctx.params()`, `ctx.currentPath()`. `spec` = {label, icon, mount(el, ctx),
  group, roles, section, hidden, order}. `:param` paths + `hidden: true` give
  you list→detail pages with no extra sidebar link.
- UI toolkit `ctx.ui`: `page`, `card`, `grid`, `stat`, `button`, `badge`,
  `empty`, `spinner`, `field`, `form`, `table`, `modal`, `confirm`, and
  `crud({doctype, title, singular, columns, fields, scope?, orderBy?})` — a
  full list+create+edit+delete page for a DocType in ONE call.
- Extension slots: `ctx.dashboard.widget(fn)` (add a dashboard tile),
  `ctx.search.provider(fn)` (feed results into the global search bar).
- Utilities: `ctx.h(tag, attrs, children)` (hyperscript), `ctx.notify(msg, kind)`,
  `ctx.icon(name)`, `ctx.fmt.{date,datetime,number,since}`,
  `ctx.store.{get,set}` (namespaced localStorage).
- Icon names: home, grid, note, user, users, box, settings, chart, plus, edit,
  trash, search.

### Manifest — `marketplace_manifest.json`
Field names are exact; the install engine reads `declares.doctypes_created` and
`declares.roles[].role_name` (NOT `doctypes` / `name`).

    {
      "slug": "tasks", "name": "Tasks", "publisher": "Acme",
      "version": "1.0.0", "class": "declarative", "kind": "app",
      "declares": {
        "doctypes_created": [
          { "name": "Task",
            "fields": [
              { "fieldname": "title", "fieldtype": "Data", "label": "Title", "reqd": 1 },
              { "fieldname": "done",  "fieldtype": "Check", "label": "Done" }
            ],
            "permissions": [
              { "role": "Task User", "read": 1, "write": 1, "create": 1, "delete": 1, "if_owner": 1 }
            ]
          }
        ],
        "roles": [ { "role_name": "Task User", "desk_access": 0 } ],
        "custom_fields": [], "fixtures": []
      },
      "bundle": {
        "entry": "main.js", "styles": ["main.css"],
        "routes": [ { "path": "/tasks", "label": "Tasks", "icon": "grid" } ]
      }
    }

- `if_owner: 1` on the permission is what makes each end user see ONLY their own
  rows — the core multi-tenant-safety rule. Use it for all end-user data.
- A **module** app sets `bundle.entry`; a **shell** app sets `bundle.shell_entry`
  instead (and declares no module routes).

### Process (author → run) — each step is a whitelisted API
1. Author the manifest + `main.js` (+ `main.css`).
2. Package: zip the bundle; record its sha256.
3. Submit a `Marketplace Item Version` (Draft) carrying the manifest.
4. Install onto a Micro SaaS: `api.marketplace.check_compatibility(item,
   version, micro_saas)` then `api.marketplace.install(item, version,
   micro_saas)`. Control ships the payload to the tenant — creates the
   DocTypes/roles and regenerates `apps.json` (which the shell reads to import
   modules + prebuild the sidebar).
5. Iterate live with the tenant `files_api` (draft → publish → rollback — see
   "Frontend files" above). Preview a draft at the app hostname with
   `?_preview=draft`.

### Security rules on the app hostname
- Only `/api/resource/*` and your own whitelisted `/api/method/<your.method>`
  are reachable; `frappe.client.*` and `frappe.desk.*` are 404 by design.
- Owner-scope end-user data with `if_owner` permissions (above).
- No external hosts: the CSP blocks remote scripts/styles/fonts — inline
  everything and ship plain ES modules + CSS. There is no build step.

### Creating DocTypes at runtime — the Schema API (fast path)

Two ways to create DocTypes; pick per situation:
- **Manifest** (above) — for a packaged, installable app. Versioned, snapshotted,
  uninstallable. Use this for anything you ship through the marketplace.
- **Schema API** — create/evolve DocTypes LIVE on a tenant, no package cycle.
  Ideal for an agent shaping data models on the fly while iterating. Called on
  the TENANT Desk hostname (`<slug>.<domain>`); requires the `Schema Manager`
  role. NOTE: `X-Dry-Run: true` is honoured only by CONTROL-plane endpoints
  (platform_control's idempotency middleware) — the tenant schema_api ignores
  the header and writes for real. To preflight, lint the shape with
  `platform_control.api.marketplace.lint_manifest` or inspect first via
  `list_doctypes` / `get_json`.

  - `microsaas_desk_theme.schema_api.list_doctypes(limit?)` — GET, your custom DocTypes.
  - `microsaas_desk_theme.schema_api.create_doctype(name, fields, permissions?)`
    — POST. `fields` = `[{fieldname, fieldtype, label, reqd?, options?}]`
    (Link/Table/Select need `options`). `permissions` (optional) =
    `[{role, read, write, create, delete, if_owner}]` — a role that doesn't
    exist yet is auto-created (deskless), so ONE call yields an end-user-ready,
    owner-scoped DocType. Omit `permissions` → admin-only.
  - `microsaas_desk_theme.schema_api.add_field(doctype, field_spec)` — POST,
    append a field to a custom DocType.
  - `microsaas_desk_theme.schema_api.get_json(doctype)` — GET the full DocType JSON.
  - `microsaas_desk_theme.schema_api.get_dts(doctype)` — GET a generated
    TypeScript `.d.ts` interface for the DocType (type your frontend for free).

  Example — a live, end-user-ready "Ticket" DocType in one call:

      POST /api/method/microsaas_desk_theme.schema_api.create_doctype
      { "name": "Ticket",
        "fields": [
          {"fieldname":"subject","fieldtype":"Data","label":"Subject","reqd":1},
          {"fieldname":"status","fieldtype":"Select","label":"Status","options":"Open\nClosed"},
          {"fieldname":"priority","fieldtype":"Select","label":"Priority","options":"Low\nHigh"} ],
        "permissions": [
          {"role":"Ticket User","read":1,"write":1,"create":1,"delete":1,"if_owner":1} ] }

  Then end users with `Ticket User` do CRUD at `/api/resource/Ticket`, seeing
  only their own rows. Wire a UI with `ctx.ui.crud({doctype:"Ticket", ...})`.

### Giving an autonomous agent its own tenant token

The Schema API needs the `Schema Manager` role, so an autonomous agent needs a
TENANT identity holding it. Two supported ways to authenticate an agent to a
tenant:
  1. **As the owner** — the owner already holds `Schema Manager` (the Owner
     role profile grants it), so an agent driving the owner's session can call
     schema_api directly.
  2. **A dedicated agent identity** (recommended for autonomy) — the owner (a
     `App Admin`) creates a tenant User holding BOTH `AI Agent` and
     `Schema Manager`, then mints it a scoped API key:
       `POST microsaas_desk_theme.tenant_api_keys.issue_agent_api_key(user)`
       → returns `{api_key, api_secret}` ONCE. (Rotate with
       `rotate_agent_api_key(user)`, revoke with `revoke_agent_api_key(user)`.)
     The agent then authenticates every tenant call with
       `Authorization: token <api_key>:<api_secret>`
     — note: a plain tenant-User token needs NO `X-Auth-Source`
     header (that header is only for control-plane API Credentials). The minting
     rules refuse Administrator, refuse a target more privileged than the
     caller, and require the target to hold `AI Agent`.

### CRUD over `/api/resource` — the exact calls (agent, server-to-server)

The SPA `ctx.api.*` helpers wrap these; an agent can hit them directly. Owner /
DocType permissions apply. Non-GET needs the session CSRF token (from
`microsaas_desk_theme.session_api.whoami`) OR an API token.

    # list (fields + filters are JSON):
    GET  /api/resource/Ticket?fields=["name","subject","status"]&filters=[["status","=","Open"]]&limit_page_length=20
    # one doc:
    GET  /api/resource/Ticket/<name>
    # create:
    POST /api/resource/Ticket            {"subject":"Login broken","status":"Open"}
    # update (partial):
    PUT  /api/resource/Ticket/<name>     {"status":"Closed"}
    # delete:
    DELETE /api/resource/Ticket/<name>

Filters use the platform's standard shape `[[field, operator, value], ...]` (operators:
`=,!=,>,<,>=,<=,like,in,not in,between`).

### End-user auth (tenant)

Self-signup for a tenant's END USERS, on the tenant hostname:

- `microsaas_desk_theme.end_user_auth.configure_signup(enabled, roles,
  allowed_domains?)` — POST, owner-only. Enabling requires at least one
  existing, deskless, non-privileged role.
- `microsaas_desk_theme.end_user_auth.signup_config()` — GET, guest.
  Returns `{enabled}` only (never leaks the role list).
- `microsaas_desk_theme.end_user_auth.register(email, password, full_name?)`
  — POST, guest. Creates a tenant-native Website User carrying ONLY the
  configured non-privileged roles, logs them in, returns the whoami shape.
- `microsaas_desk_theme.end_user_auth.request_password_reset(email)` —
  POST, guest. Always answers generically.

Auth endpoints (login / register / password reset) are throttled by the
tighter `auth` rate-limit bucket (next section).

### View as user — owner/support impersonation of an end user (§9.1c)

Desk personas are deliberately refused a session on the app hostname, so
"open my live site" lands on the end-user login. To see the SPA as a
specific customer sees it, mint a one-time view-as URL from the Desk
hostname (also in the Desk UI: floating ✦ menu → "View as user"):

- `microsaas_desk_theme.impersonation.mint_view_as_token(target_user,
  mode="read"|"write", reason)` — POST, interactive Desk sessions only
  (requires the platform-managed `Impersonator` role, held by the
  Owner/Admin/Manager/Developer/Support profiles; API tokens are refused).
  `reason` is mandatory and audited on both ends. Returns
  `{view_as_url, expires_in: 900, mode, target_user}`.
- `microsaas_desk_theme.impersonation.list_view_as_targets(search?)` —
  GET. Eligible targets only (enabled, non-Desk, ceiling-satisfying).
- Opening `view_as_url` (one-time, 15 min) starts a session on the app
  hostname AS the target; `whoami` then carries `impersonated_by`,
  `impersonation_mode`, `impersonation_expires_at`, and the shell shows a
  non-dismissible banner. The session dies with the token.
- `mode="read"` (default, T3): every non-GET request is refused with 403
  at the request layer. `mode="write"` (T4): support acting on the user's
  behalf — the target user gets a notification.
- Privilege ceiling, enforced at mint AND consume: the target may not
  hold `Desk Access` (teammates are never impersonatable) nor any
  platform-managed/framework role the caller lacks. `platform.secret()`
  is refused for the whole session.
- `microsaas_desk_theme.impersonation.exit_view_as` — GET; ends the
  session and bounces back to the Desk hostname.

### Tenant rate limiting

Per-tenant fixed-window limits, APP hostname only (the Desk hostname is
unthrottled). Fail-open: a broken limiter never blocks traffic.

- `microsaas_desk_theme.rate_limit.configure_rate_limit(enabled?,
  api_per_min?, auth_per_min?)` — POST, owner-only; persists to
  site_config.
- site_config keys: `app_rate_limit_enabled`, `app_rate_limit_per_min`
  (default 300), `auth_rate_limit_per_min` (default 20).

## Billing

Two independent layers — keep them straight:

**1. The platform bills the OWNER's account** (control plane, `api.billing.*`).
Runs in mock mode by default (no Stripe account needed); the same calls hit
`api.stripe.com` once real keys are in the vault.
- `api.billing.list_plans(account?)` — GET, public priced plans (Free/Pro/…).
- `api.billing.get_billing_summary(account)` — GET, current plan + usage +
  subscription + invoices.
- `api.billing.start_checkout(account, plan)` — POST, returns `{checkout_url}`
  (send the owner there). On payment the subscription goes Active + an invoice is
  written + `Account.plan` flips so quota caps update.
- `api.billing.cancel_subscription(account, at_period_end=1)` — POST.
- Owners manage all this in the UI at `/billing`. Webhooks land at
  `api.billing.webhook` (signature-verified).

**2. The TENANT bills THEIR OWN end users** (tenant plane, "SaaS Billing Kit",
`microsaas_desk_theme.billing.*` on the tenant Desk host). The platform takes
**0%** and never touches this money. DocTypes (Billing Plan/Customer/
Subscription/Invoice) are preinstalled on every tenant. Keys live in the tenant's
own `Platform Secret` via `microsaas_desk_theme.helpers.secret(name)`.
- `billing.create_plan(plan_code, plan_name, amount_cents, interval, currency, ...)`.
- `billing.create_checkout_session(plan_code, customer_email)` — start a checkout.
- `billing.record_payment(payload, signature)` — the tenant's Stripe webhook
  (HMAC-verified); marks Invoices Paid + Subscriptions Active.
- `billing.list_plans()`, `billing.list_subscriptions()`.

### Completing a mock checkout

Control plane: `api.billing.start_checkout(account, plan)` returns a
`checkout_url` (mock mode: our own hosted page). In mock mode, complete
via `api.billing.complete_mock_checkout(account, plan, session?)` —
owner-scoped, refused outside mock mode — the exact state change the
Stripe webhook drives in live mode (subscription Active + Paid invoice +
`Account.plan` flips). Live mode completes ONLY via the webhook.

Tenant plane is analogous:
`microsaas_desk_theme.billing.complete_mock_checkout(plan_code,
customer_email)` — the caller must be that customer themselves or hold
App Admin; you cannot complete a checkout for someone else's email.

## Error contract

Every refusal returns a typed envelope on `frappe.local.response["error"]`:

```
{
  "code": "invalid_slug",                       # stable identifier
  "message": "Slug 'foo' is not a valid ...",   # human-readable
  "hint": "Slug must be 3-31 characters ...",   # for an agent to self-correct
  "docs_url": "https://.../invalid_slug"        # deep link
}
```

Read `hint` first — it is written for you to fix the request and retry
without a round trip to a human.

## Blast-radius tiers (design doc §6.5)

- T0 — GETs, dry-runs. Always allowed.
- T1 — small writes. Allowed within your token budget.
- T2 — bulk writes, DocType create, file delete. Allowed, flagged in audit.
- T3 — bulk delete, drop DocType, credential rotate, invite user.
- T4 — delete site/account, plan change, payment methods, Developer Mode
       enable, `end_user_type=system` switch.

What is ENFORCED today: **your token's `scope_preset` is a hard per-call
ceiling** (2026-08-15) — every control-plane endpoint carries a tier
(read_only→T0, read_write→T1, build→T2, admin→T3); calling above your
preset returns `403 scope_preset_exceeded` with the required preset named
in the hint. GETs are always T0; structural calls (install/upgrade/
archive/provision/promote) are T2; destructive or secret-touching calls
(delete/restore/reveal/rotate/issue/jump) are T3. `api.approvals.approve`
/ `.deny` refuse EVERY non-staff token call outright
(`403 approval_decision_requires_human`) — an agent cannot approve its
own requests. Also enforced: Token Budget caps (`429
token_budget_exceeded`), the container-tier gate on `native` installs
(`403 app_requires_container`), one-time credential reveal (409 on a
second read), the slug-typed confirmation on `api.lifecycle.delete`
(`confirm` must equal the slug), AND the T3/T4 approval gate for
TOKEN-authenticated calls:

  `credentials.rotate`, `credentials.reveal`, `lifecycle.delete`,
  `lifecycle.restore`, `domains.remove_custom_domain` refuse a token call
  with `428 approval_not_yet_approved` unless you pass
  `approval_ok=<approval_id>` referencing an APPROVED, unexpired
  `Approval Request` for the same action + Micro SaaS. Your flow:
    1. `api.approvals.request_approval(action=..., micro_saas=...)`
       -> returns `approval_id` (status Pending).
    2. A human approves it in the control Desk (or
       `api.approvals.approve(approval_id)` by an authorized approver).
    3. Re-call the T3 endpoint with `approval_ok=<approval_id>`.
  Interactive Desk sessions (a human clicking) and platform staff are not
  gated. Site operators can disable with `enforce_t3_approvals: 0`.

## Idempotency & determinism

- `Idempotency-Key` header on any non-GET `/api/` call. First call claims
  the key and runs; a 2xx response is stored 24h. A RETRY with the same
  key + body returns the stored response verbatim (look for the
  `Idempotency-Replayed: true` response header) and does NOT re-execute.
  A concurrent duplicate while the first is in flight gets
  `409 idempotency_conflict`. Non-2xx outcomes are never cached — retry
  re-executes. Keys are scoped per user + method + path + body, so reusing
  a key for a different call is safe (it's a different claim).
  Use it on every non-GET call.
- `X-Dry-Run: true`: control-plane endpoints only — returns the intended
  diff without writing. Tenant-side methods (schema_api etc.) ignore it.
- File writes: `if_match_sha` — 409 on stale.

## Rate limits & budgets

Every token has a `Token Budget` (writes_per_hour_max, writes_per_day_max,
cost_per_day_usd_max). Breach returns `429` with `retry_after` and pings the
Owner. A credential with no budget row is NOT unlimited — the platform
self-heals a default budget (600/h, 5000/d) on first use. Kill switch:
`POST /api/method/platform_control.api.credentials.revoke` with
`{name: <API Credential name>}` — irreversible; issue a new credential
to resume.

## When you're stuck

- `hint` fields on 4xx envelopes are the fastest fix path.
- `/api/method/platform_control.api.docs.get_openapi_json` is the full
  spec, machine-parseable.
- Support is a human loop, not a token loop.
