{
  "mcp": {
    "url": "https://mcp.spaicely.com/mcp",
    "public_url": "https://mcp.spaicely.com/mcp/public",
    "instructions": "Spaicely hosts static websites on the customer's own domain in the EU. You act for one person and one account; start with account.whoami to learn which sites you may see and what you may change; with no site yet, sites.create makes one on that account. Your rights are never wider than the person's role on a site. Purchases always need a second call with a confirmation token, after the person agreed. The person chooses per kind of action whether you may do it yourself, must ask first, or may not (account.whoami lists them as ai_permissions). A call that must be asked first answers with approval_required and a url: give the person that link, do not repeat the call, and read the outcome with approvals.status once they say they decided. The call runs exactly as you sent it. Give every site its own 404.html at the root, in the style and language of the site: visitors get it for any address that does not exist. Without one they get a plain page of ours in the language of the lang attribute of index.html. Every site also needs robots.txt, sitemap.xml and llms.txt at the root, an icon, and on each page its own title, meta description, lang, canonical link and Open Graph tags for link previews, all with full https URLs on the site's domain; describe the business in JSON-LD. The full checklist with examples: https://spaicely.com/skills/en/site-building/SKILL.md (also nl, de, fr). Running a site (versions, rollback, domain, DNS, mail forwarding, people, plan): https://spaicely.com/skills/en/site-management/SKILL.md. After publishing, the hints in sites.versions say what is still missing. Once the hosting is paid, a published site is also at its own address (address in account.whoami), to share before a domain is connected. A static site can still take messages, signups and payments: scripts.enable adds a form handled by Spaicely and gives the HTML to put in a page. Kinds: contact (mail to the owner), signup (a list with double opt-in), download (a file under _private/ for the visitor's details), coupon (a discount code; one for everyone, a list, or codes with dates such as a code per week) and donation (any plan: amounts paid to the owner's own Stripe account, a progress bar from progress_url). A download or coupon form can take a price. Use these instead of a form or payment service elsewhere. A site with the Shop plan sells through shop.setup: Stripe Checkout on the owner's own Stripe account, prices from a products file in the site (at most 500 products). Payments carry a 2% commission. A person who may change files but not publish asks for approval with sites.review.request (Pro and Shop plans); sites.stats gives visits without cookies. A partner proposes a domain to a customer with domains.propose and hands a finished site over with sites.handover. sites.subscription shows and starts or switches a site's hosting plan; account.billing holds the billing details every purchase needs. Mail to a connected domain can be forwarded to mailboxes the person already has (info@ to their Gmail): mail.forward.enable, then mail.forward.add; EUR 1.50 (GBP 1.30, USD 1.75) a month per address, Site 1, Pro 5, Shop 10. Answering as info@ is not possible yet. The prompts new_site and launch_check describe how we like a site built and checked; move_site rebuilds a site from elsewhere, such as WordPress, with the same pages and addresses. Spaicely adds tools over time. Fetch the tool list again when you can; when a tool named here or on https://spaicely.com/developers/ is not in your list, ask the person to reconnect the Spaicely connector (in claude.ai: Settings > Connectors), which loads the current list.",
    "tools": [
      {
        "name": "account.whoami",
        "title": "Who am I working for?",
        "scope": "account:read",
        "public": false,
        "description": "Returns the person and account you act for, your scopes, and per site your role and what you may do. Call this first.",
        "input_schema": {
          "properties": {},
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "approvals.status",
        "title": "Approvals the person must give",
        "scope": "account:read",
        "public": false,
        "description": "A call of a kind of action the person set to `ask` (see `ai_permissions` in account.whoami) does not run at once: it answers with `approval_required`, an `approval` id and a `url` for the person. Do not repeat that call. Once the person says they decided, pass the `approval` id here: `status` is pending, approved, rejected or expired, and for an approved call `result` is the answer the call gave, exactly as if it had run at once. Without an id: the approvals of this account that still wait. Needs scope account:read.",
        "input_schema": {
          "properties": {
            "approval": {
              "type": "string"
            }
          },
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "account.members",
        "title": "People on the account",
        "scope": "access:manage",
        "public": false,
        "description": "Lists the people on the account you act for, with their role (owner or member) and the open invitations. Everyone on an account works on all its sites; owners also handle payment, domains and people. For owners: `invite` mails a link, valid for 7 days, to an address (role member or owner); `set_role` with `user_id` and `role` changes a role; `remove` with `user_id` takes someone off; `cancel_invitation` with an e-mail address withdraws an invitation. People on a single site instead of the whole account: access.update. Ask the person first. Needs scope access:manage.",
        "input_schema": {
          "properties": {
            "invite": {
              "type": "string",
              "description": "E-mail address to invite"
            },
            "role": {
              "type": "string",
              "enum": [
                "member",
                "owner"
              ],
              "description": "For invite and set_role; default member"
            },
            "set_role": {
              "type": "boolean"
            },
            "remove": {
              "type": "boolean"
            },
            "user_id": {
              "type": "integer",
              "description": "For set_role and remove, from the list"
            },
            "cancel_invitation": {
              "type": "string",
              "description": "E-mail address of an open invitation"
            }
          },
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": false,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "account.billing",
        "title": "Billing details of the account",
        "scope": "billing:write",
        "public": false,
        "description": "Shows the billing details of the account you act for: whether it is a business, VAT number, address and country, and changes them with the fields you pass. They go on every invoice and decide the VAT: a business in another EU country with a VAT number that VIES confirms pays no Dutch VAT. A VAT number is checked with VIES on saving. Hosting and domains cannot be bought without them. Only owners. Needs scope billing:write.",
        "input_schema": {
          "properties": {
            "business": {
              "type": "boolean"
            },
            "vat_number": {
              "type": "string"
            },
            "billing_street": {
              "type": "string"
            },
            "billing_postal_code": {
              "type": "string"
            },
            "billing_city": {
              "type": "string"
            },
            "billing_country_code": {
              "type": "string",
              "description": "Two letters, such as NL"
            }
          },
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "account.invoices",
        "title": "Invoices of the account",
        "scope": "billing:write",
        "public": false,
        "description": "Lists Spaicely's invoices to the account you act for, newest first (at most 50): number, date, amounts in euro cents and a `url` where the person reads and prints it. A credit invoice has a negative total. Only owners. Needs scope billing:write.",
        "input_schema": {
          "properties": {},
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "domains.search",
        "title": "Check which domain names are free",
        "scope": null,
        "public": true,
        "description": "Checks whether domain names are free to register. Pass full domains (bakkerijjansen.nl) and/or bare names (bakkerijjansen); a bare name is tried with every extension in `extensions`, or by default with the ones people ask for most: nl, com, eu, be, de, net, org, shop, online, site, store, tech, app, dev, io, ai, co.uk, uk, fr, at, ch, it, es, nu, se, co, me, info, biz, xyz, cloud. We sell 718 extensions in all (.amsterdam, .restaurant, .photography and so on); pass them in `extensions` when they fit the name. At most 50 domains per call. The first check is quick and asks DNS only: taken means the domain has nameservers (certain); likely_available means it has none, so it is probably free (reason dns_only, or no_rdap where the registry has no RDAP and verifying adds nothing). Once the person has favourites, call again with verify: true for the dns_only ones (at most 10 domains): that asks our registrar (or else the registry over RDAP) and turns them into available or taken; otherwise reason says why (rdap_rate_limited, rdap_unavailable). A premium name (premium: true) costs what its registry asks: price is our price for it in euro cents before VAT, often far above the usual price, so tell the person. unknown means no answer at all. Each free domain has a register_url: a page where the person signs in and registers it in their own name. Registration itself checks again. Each taken domain has a transfer_url: if the domain is the person's own, they move it to Spaicely there.",
        "input_schema": {
          "properties": {
            "names": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "minItems": 1,
              "description": "Domains or bare names, such as bakkerijjansen.nl or bakkerijjansen"
            },
            "extensions": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "Extensions to try bare names with, such as nl, com, eu. Default: the ones people ask for most."
            },
            "verify": {
              "type": "boolean",
              "description": "Deep check for a shortlist: also ask the registries. At most 10 domains."
            }
          },
          "required": [
            "names"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": true
        }
      },
      {
        "name": "domains.register",
        "title": "Register a domain",
        "scope": "domains:buy",
        "public": false,
        "description": "Registers a domain in the name of the customer account you act for; that account becomes the holder. Call it first without `confirmation`: it checks the name and the holder details and returns a summary with the price and a confirmation. Show the summary to the person and ask whether to go ahead; only then call again with the same name and that confirmation. That call returns a `payment_url`: give it to the person, who pays there; the domain is registered once the payment is in. Holder details are needed once per account (pass `contact`); later domains reuse them. A consumer (an account that is not a business) must first agree to have the order carried out at once and to lose the 14-day right of withdrawal once it is done; ask them, and pass `start_now: true` with the second call only when they said yes themselves. Without it a consumer cannot order. Automatic renewal is on by default and charges the card or direct debit used for this payment; see domains.auto_renew. Needs scope domains:buy.",
        "input_schema": {
          "properties": {
            "name": {
              "type": "string",
              "description": "The full domain, such as bakkerijjansen.nl"
            },
            "contact": {
              "type": "object",
              "description": "The holder as the registry records it. Leave out to reuse the last one.",
              "properties": {
                "name": {
                  "type": "string"
                },
                "organization": {
                  "type": "string"
                },
                "email": {
                  "type": "string"
                },
                "phone": {
                  "type": "string",
                  "description": "With country code, such as +31 6 12345678"
                },
                "street": {
                  "type": "string",
                  "description": "Street and house number"
                },
                "postal_code": {
                  "type": "string"
                },
                "city": {
                  "type": "string"
                },
                "country_code": {
                  "type": "string",
                  "description": "Two letters, such as NL"
                }
              }
            },
            "confirmation": {
              "type": "string",
              "description": "From the first call, after the person agreed."
            },
            "start_now": {
              "type": "boolean",
              "description": "Required for consumers, with the second call: carry out the order at once and give up the 14 days to withdraw. Only when the person said so themselves."
            }
          },
          "required": [
            "name"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "domains.propose",
        "title": "Propose a domain to the customer",
        "scope": "domains:buy",
        "public": false,
        "description": "For a partner working on a customer's site (as admin, maintainer or designer): proposes a domain for that site. The customer becomes the holder and gets a mail with a link, valid for 14 days, to check the holder details and agree; nothing is ordered before that. `payer` is `customer` (the customer pays when agreeing) or `partner` (an owner of the partner account pays afterwards, on the page in `proposal_url`). `contact` fills in the holder details for the customer to check; leave it out to let the customer fill them in. Ask the person first. Needs scope domains:buy.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "name": {
              "type": "string",
              "description": "The full domain, such as bakkerijjansen.nl"
            },
            "payer": {
              "type": "string",
              "enum": [
                "customer",
                "partner"
              ]
            },
            "contact": {
              "type": "object",
              "description": "The holder (the customer) as the registry records it; optional.",
              "properties": {
                "name": {
                  "type": "string"
                },
                "organization": {
                  "type": "string"
                },
                "email": {
                  "type": "string"
                },
                "phone": {
                  "type": "string",
                  "description": "With country code, such as +31 6 12345678"
                },
                "street": {
                  "type": "string",
                  "description": "Street and house number"
                },
                "postal_code": {
                  "type": "string"
                },
                "city": {
                  "type": "string"
                },
                "country_code": {
                  "type": "string",
                  "description": "Two letters, such as NL"
                }
              }
            }
          },
          "required": [
            "site_id",
            "name"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "domains.transfer",
        "title": "Move a domain to Spaicely",
        "scope": "domains:buy",
        "public": false,
        "description": "Moves a domain that is registered elsewhere to Spaicely, in the name of the customer account you act for; that account is the holder. The person gets the transfer code (also called auth code, EPP code or token) from their current registrar and must unlock the domain there. Do not ask for the code in the conversation: call with only `name`. That checks whether the domain can move and returns a `url` where the person signs in, enters the code and the holder details and confirms; give them that link. domains.status shows the move afterwards. Only when the person pastes the code into the conversation themselves: call with `name` and `transfer_code` and without `confirmation` for a summary and a confirmation, ask the person, then call again with the same name, the same transfer_code and that confirmation; that call returns a `payment_url` for the person, and the move starts once the payment is in. A consumer must agree to `start_now` first (see domains.register). Automatic renewal is on by default (domains.auto_renew). Needs scope domains:buy.",
        "input_schema": {
          "properties": {
            "name": {
              "type": "string",
              "description": "The full domain, such as bakkerijjansen.nl"
            },
            "transfer_code": {
              "type": "string",
              "description": "Only when the person gave it in the conversation themselves"
            },
            "contact": {
              "type": "object",
              "description": "The holder as the registry records it. Leave out to reuse the last one.",
              "properties": {
                "name": {
                  "type": "string"
                },
                "organization": {
                  "type": "string"
                },
                "email": {
                  "type": "string"
                },
                "phone": {
                  "type": "string",
                  "description": "With country code, such as +31 6 12345678"
                },
                "street": {
                  "type": "string",
                  "description": "Street and house number"
                },
                "postal_code": {
                  "type": "string"
                },
                "city": {
                  "type": "string"
                },
                "country_code": {
                  "type": "string",
                  "description": "Two letters, such as NL"
                }
              }
            },
            "confirmation": {
              "type": "string",
              "description": "From the first call, after the person agreed."
            },
            "start_now": {
              "type": "boolean",
              "description": "Required for consumers, with the second call: carry out the order at once and give up the 14 days to withdraw. Only when the person said so themselves."
            }
          },
          "required": [
            "name"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "domains.auto_renew",
        "title": "Automatic renewal of a domain",
        "scope": "domains:buy",
        "public": false,
        "description": "Shows whether a domain held by the account you act for renews automatically, and switches it on or off with `on`. Automatic renewal is on by default: Spaicely charges the card or SEPA direct debit the domain was paid with, 14 days before the expiry date, and e-mails the amount beforehand. When no payment method is saved, the answer has a `setup_url`: give it to the person, who saves a card or mandate at Stripe there. Leave `on` out to only look. A consumer (an account that is not a business) who switches it on must agree that each renewal is carried out at once, without the 14-day right of withdrawal; pass `start_now: true` only when they said so themselves. Only owners of the holder account can change it. Needs scope domains:buy.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string",
              "description": "The full domain, such as bakkerijjansen.nl"
            },
            "on": {
              "type": "boolean",
              "description": "true to switch on, false to switch off; leave out to only look."
            },
            "start_now": {
              "type": "boolean",
              "description": "For consumers switching it on: renewals start at once, without the right of withdrawal."
            }
          },
          "required": [
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "domains.renew",
        "title": "Renew a domain now",
        "scope": "domains:buy",
        "public": false,
        "description": "Gives a link where the person pays for the renewal of a domain registered through Spaicely now, instead of waiting for automatic renewal (domains.auto_renew); the domain renews after payment, for the period in the answer. A consumer (an account that is not a business) must agree that the renewal is carried out at once, without the 14-day right of withdrawal; pass `start_now: true` only when they said so themselves. Only owners of the holding account. Needs scope domains:buy.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            },
            "start_now": {
              "type": "boolean"
            }
          },
          "required": [
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "domains.holder",
        "title": "Holder of a domain",
        "scope": "domains:buy",
        "public": false,
        "description": "Shows the holder of a domain registered through Spaicely, as the registry knows it, and changes it with the fields you pass. Address, phone and e-mail go to the registry at once, for every domain on the same holder record (shared_with lists them). Another name or organisation is a change of holder: Spaicely staff handle it, which can take a few working days. The holder is the legal owner of the domain: change it only with the person's own details or what they said. Only owners of the holding account. Needs scope domains:buy.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            },
            "name": {
              "type": "string"
            },
            "organization": {
              "type": "string"
            },
            "email": {
              "type": "string"
            },
            "phone": {
              "type": "string"
            },
            "street": {
              "type": "string"
            },
            "postal_code": {
              "type": "string"
            },
            "city": {
              "type": "string"
            },
            "country_code": {
              "type": "string",
              "enum": [
                "NL",
                "BE",
                "DE",
                "FR",
                "AT",
                "CH",
                "LU",
                "GB",
                "IE",
                "IT",
                "ES",
                "PT",
                "DK",
                "SE",
                "NO",
                "FI",
                "PL",
                "CZ",
                "US"
              ]
            }
          },
          "required": [
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "domains.lock",
        "title": "Transfer lock of a domain",
        "scope": "domains:buy",
        "public": false,
        "description": "Switches the transfer lock of a domain registered through Spaicely on or off. With the lock on, nobody can move the domain to another provider, even with the transfer code. Switch it off only when the person moves the domain away; domains.status shows the lock. Only owners of the holding account. Needs scope domains:buy.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            },
            "locked": {
              "type": "boolean"
            }
          },
          "required": [
            "domain",
            "locked"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "domains.nameservers",
        "title": "Nameservers of a domain",
        "scope": "domains:buy",
        "public": false,
        "description": "Sets the nameservers of a domain registered through Spaicely. `ours: true` puts back Spaicely's own (needed for a site and for mail forwarding). Other nameservers (2 to 13) only for a domain that is not on a site, for instance when the person keeps their DNS elsewhere; Spaicely's DNS then no longer applies. Ask the person first. Only owners of the holding account. Needs scope domains:buy.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            },
            "ours": {
              "type": "boolean"
            },
            "nameservers": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "description": "Host names, such as ns1.example.net"
            }
          },
          "required": [
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "domains.transfer_code",
        "title": "Transfer code of a domain",
        "scope": "domains:buy",
        "public": false,
        "description": "Asks for the transfer code (auth code) of a domain registered through Spaicely, to move it to another provider. The code goes by mail to the person and the holder, not to you; every new request makes the earlier code useless, so ask at most a few times an hour. The transfer lock must be off at the moment of the move (domains.lock). Ask the person first. Only owners of the holding account. Needs scope domains:buy.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            }
          },
          "required": [
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.create",
        "title": "Create a site",
        "scope": "content:write",
        "public": false,
        "description": "Makes a new, empty site on the account you act for, owned and paid for by that account. Then sites.deploy and sites.publish put the first version on it, and sites.domains.connect puts it on the person's domain. Ask the person for the name first. Needs scope content:write and the role owner or member of a customer account. A site needs a hosting subscription: without one it is removed after 14 days; tell the person and give them the subscription_url from the answer. Once paid and published, the site also answers on its address (<label>.spaicely.site, in the answer), to share before a domain is connected; search engines are told to skip it.",
        "input_schema": {
          "properties": {
            "name": {
              "type": "string",
              "description": "Name of the site as the person calls it"
            }
          },
          "required": [
            "name"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.update",
        "title": "Rename a site",
        "scope": "access:manage",
        "public": false,
        "description": "Gives a site another name, at most 80 characters. The name is only for the person and the people they work with; visitors never see it. Needs scope access:manage and the role owner or admin.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "name": {
              "type": "string",
              "maxLength": 80
            }
          },
          "required": [
            "site_id",
            "name"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.delete",
        "title": "Delete a site",
        "scope": "access:manage",
        "public": false,
        "description": "Deletes a site for good: it goes offline at once, and every version, form and the shop disappear. The hosting stops without a refund for the current month; a consumer who can still withdraw should do that first (sites.subscription shows withdrawable). Connected domains stay the person's, with their DNS and mail. This cannot be undone: tell the person exactly what goes, and pass `confirm_name` only as the site name they typed or said themselves. Only owners of the site. Needs scope access:manage.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "confirm_name": {
              "type": "string",
              "description": "The name of the site, as the person confirmed it"
            }
          },
          "required": [
            "site_id",
            "confirm_name"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": false,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.subscription",
        "title": "Hosting plan of a site",
        "scope": "billing:write",
        "public": false,
        "description": "Shows the hosting plan of a site: plan, status, the date it renews or ends, the paying account, and whether a consumer can still withdraw. Pass `plan` (site, pro or shop) to start hosting when the site has none: the answer has a `checkout_url` where the person pays at Stripe; give it to them. On a site that has a plan, `plan` switches to it straight away; Stripe settles the rest of the month on the next invoice. Card, invoices and cancelling are at `portal_url`. A consumer (an account that is not a business) who starts hosting must agree that it starts at once, which limits the right of withdrawal to the unused days; pass `start_now: true` only when they said so themselves. Ask the person before you start or switch a plan. Only owners of the paying account. Needs scope billing:write.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "plan": {
              "type": "string",
              "enum": [
                "site",
                "pro",
                "shop"
              ],
              "description": "Start this plan, or switch to it; leave out to only look. Site EUR 9, Pro EUR 19, Shop EUR 39 a month excl. VAT (GBP 8/17/34 in the United Kingdom, USD 11/22/45 outside Europe)."
            },
            "start_now": {
              "type": "boolean",
              "description": "For consumers starting a plan: hosting starts at once."
            }
          },
          "required": [
            "site_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.versions",
        "title": "List the versions of a site",
        "scope": "sites:read",
        "public": false,
        "description": "Lists the versions (deployments) of a site, newest first: number, status (draft, ready, publishing, live, failed), files and who made it. Needs scope sites:read.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            }
          },
          "required": [
            "site_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "sites.deploy",
        "title": "Start a new version of a site",
        "scope": "content:write",
        "public": false,
        "description": "Starts a new version of a site. Pass every file of the finished site (not only the changed ones) with its path from the site root, its SHA-256 in hex and its size in bytes. For an image (or any file) that is already on the web, pass source_url instead of sha256 and size: we fetch it (https, at most 20 per call, 25 MB each), so you need not send it as base64; use only images the person may use. The answer lists `missing`: send exactly those with sites.files.put. Has the site as a folder on disk? Use sites.upload_url instead. A version goes live only with sites.publish. Needs scope content:write; your role decides which paths you may change.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "files": {
              "type": "array",
              "maxItems": 2000,
              "items": {
                "type": "object",
                "required": [
                  "path"
                ],
                "properties": {
                  "path": {
                    "type": "string",
                    "description": "Such as index.html or blog/first-post.html"
                  },
                  "sha256": {
                    "type": "string"
                  },
                  "size": {
                    "type": "integer"
                  },
                  "source_url": {
                    "type": "string",
                    "description": "https URL we fetch the file from, instead of sha256 and size"
                  }
                }
              }
            }
          },
          "required": [
            "site_id",
            "files"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.files.put",
        "title": "Send files for a new version",
        "scope": "content:write",
        "public": false,
        "description": "Sends the contents of files that sites.deploy listed as missing, at most 50 files and 2 MB per call. Text files as encoding utf8, images and other binary files as base64. The contents must match the SHA-256 you listed. The answer says which files are still missing. Needs scope content:write.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "deployment": {
              "type": "integer",
              "description": "The number sites.deploy returned"
            },
            "files": {
              "type": "array",
              "maxItems": 50,
              "items": {
                "type": "object",
                "required": [
                  "path",
                  "content"
                ],
                "properties": {
                  "path": {
                    "type": "string"
                  },
                  "content": {
                    "type": "string"
                  },
                  "encoding": {
                    "type": "string",
                    "enum": [
                      "utf8",
                      "base64"
                    ],
                    "default": "utf8"
                  }
                }
              }
            }
          },
          "required": [
            "site_id",
            "deployment",
            "files"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.upload_url",
        "title": "Get an upload address for a whole site",
        "scope": "content:write",
        "public": false,
        "description": "Returns a one-time URL, valid 10 minutes, that takes the finished site as one tar.gz archive with index.html at its root. Upload with `tar -czf site.tar.gz -C <folder> . && curl -T site.tar.gz <url>`; the answer to that request is the new version. Then call sites.publish. Needs scope content:write.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            }
          },
          "required": [
            "site_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.publish",
        "title": "Put a version of a site live",
        "scope": "sites:publish",
        "public": false,
        "description": "Puts a ready version live; visitors switch over at once, never to a mix of two versions. Pass `deployment`, or `rollback: true` to bring back the version that was live before. It takes up to half a minute; sites.versions shows when it is live, with a report whose `hints` say what the site still lacks (sitemap, social tags, alt texts...), each with a `fix`. If someone published after your version was started, your changes are merged onto theirs and that merge goes live; a conflict comes back per path. Ask the person before you publish. Needs scope sites:publish and a role that may publish.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "deployment": {
              "type": "integer"
            },
            "rollback": {
              "type": "boolean"
            }
          },
          "required": [
            "site_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.review.request",
        "title": "Ask for approval of a version",
        "scope": "content:write",
        "public": false,
        "description": "For a person who may change files but not publish (sites.publish refuses them): asks the people who may publish to approve a ready version. They get a mail and put it live from the dashboard or with sites.publish, or turn it down with a note; sites.versions shows the state under `review`. Pass a short `note` on what changed. Only on plan Pro and Winkel; on plan Site the person asks someone who may publish. Needs scope content:write.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "deployment": {
              "type": "integer"
            },
            "note": {
              "type": "string",
              "maxLength": 1000
            }
          },
          "required": [
            "site_id",
            "deployment"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.review.reject",
        "title": "Send a version back",
        "scope": "sites:publish",
        "public": false,
        "description": "Sends back a version someone asked approval for with sites.review.request, with a `note` that tells them what to change; they get it by mail. To approve, publish the version with sites.publish. Ask the person first. Needs scope sites:publish and a role that may publish.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "deployment": {
              "type": "integer"
            },
            "note": {
              "type": "string",
              "description": "What should change, for whoever asked"
            }
          },
          "required": [
            "site_id",
            "deployment"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.files.list",
        "title": "List the files of a site",
        "scope": "sites:read",
        "public": false,
        "description": "Lists the files of the live version of a site, or of `deployment`: path, SHA-256 and size. HTML pages also list their regions, the blocks marked <!-- spaicely:edit name --> or <!-- spaicely:append name --> that a role can be limited to. Pass `prefix` to list one folder. Fetch contents with sites.files.get. Needs scope sites:read.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "deployment": {
              "type": "integer"
            },
            "prefix": {
              "type": "string",
              "description": "Such as blog/"
            }
          },
          "required": [
            "site_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "sites.files.get",
        "title": "Read files of a site",
        "scope": "sites:read",
        "public": false,
        "description": "Returns the contents of up to 20 files (1 MB together) of the live version of a site, or of `deployment`, exactly as they were uploaded. Text files come back as utf8, others as base64. Read a page before you change it, so you keep what others made. Needs scope sites:read.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "deployment": {
              "type": "integer"
            },
            "paths": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "maxItems": 20
            }
          },
          "required": [
            "site_id",
            "paths"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "sites.region.update",
        "title": "Change one region of a page",
        "scope": "content:write",
        "public": false,
        "description": "Makes a new version that differs from the live one in one region of one page: the block between <!-- spaicely:edit name --> and <!-- /spaicely:edit --> (or spaicely:append). `mode` replace (the default) swaps the contents, prepend or append adds HTML before or after them; an append region only takes prepend or append. `index` picks the region when a page has several with the same name (0 is the first). sites.files.list shows the regions. Then ask the person and call sites.publish. Needs scope content:write.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "path": {
              "type": "string",
              "description": "Such as index.html"
            },
            "region": {
              "type": "string"
            },
            "content": {
              "type": "string",
              "description": "HTML"
            },
            "mode": {
              "type": "string",
              "enum": [
                "replace",
                "prepend",
                "append"
              ]
            },
            "index": {
              "type": "integer",
              "minimum": 0
            }
          },
          "required": [
            "site_id",
            "path",
            "region",
            "content"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.stats",
        "title": "Visits of a site",
        "scope": "sites:read",
        "public": false,
        "description": "How much a published site is visited, per day over the last 7, 30 or 90 `days` (default 30), counted by the CDN without cookies or scripts on the site, in total and in `per_day`: `requests` (every file served: pages, images, styles; not people), `bytes` sent, `not_found` (addresses that do not exist, a hint at broken links) and the CDN `regions` that served them, close to the visitors. Needs scope sites:read.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "days": {
              "type": "integer",
              "enum": [
                7,
                30,
                90
              ]
            }
          },
          "required": [
            "site_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "sites.domains.connect",
        "title": "Put a site on its domain",
        "scope": "dns:write",
        "public": false,
        "description": "Connects a domain to a site, so the site answers on https://<domain> and www sends people on to it. Spaicely runs the DNS of every domain it hosts. Call it with the domain itself (no www): Spaicely first looks up the records the domain has now (mail, verifications); call again after about half a minute to get them in records_to_confirm. Show those to the person: kept records come along so mail keeps working, records with reason site are replaced by the website, reason certificate means a CAA record that would block the certificate. Then call with confirm: true (and skip with the index of records to leave out). After that the person sets the nameservers at their registrar to the two in the answer; domains we registered already have them. We check every five minutes, then get the certificate and mail the person. domains.status shows the state at any time. Needs scope dns:write and a role that may change DNS.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "domain": {
              "type": "string",
              "description": "Such as bakkerijjansen.nl"
            },
            "confirm": {
              "type": "boolean",
              "description": "After the person agreed to the records in records_to_confirm."
            },
            "skip": {
              "type": "array",
              "items": {
                "type": "integer"
              },
              "description": "Indexes of found records to leave out."
            }
          },
          "required": [
            "site_id",
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.domains.disconnect",
        "title": "Take a site off a domain",
        "scope": "dns:write",
        "public": false,
        "description": "The site stops answering on the domain and on its www. Other records, such as mail, stay in Spaicely's DNS as long as the nameservers point at us. Ask the person first. Needs scope dns:write and a role that may change DNS.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            }
          },
          "required": [
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "domains.status",
        "title": "How is a domain doing?",
        "scope": "domains:read",
        "public": false,
        "description": "Shows whether a domain is connected to its site, what is still needed (records to confirm, nameservers at the registrar, DNSSEC to switch off, the certificate) and the DNS records Spaicely serves for it. current_nameservers is what the registry says now. For a domain of the account that is on no site, it shows the registry side instead: expiry, automatic renewal, transfer lock, nameservers, and the DNS records when Spaicely's DNS serves it. Needs scope domains:read.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            }
          },
          "required": [
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": true
        }
      },
      {
        "name": "domains.dns.update",
        "title": "Change DNS records",
        "scope": "dns:write",
        "public": false,
        "description": "Adds or deletes DNS records of a domain that is connected to a site, or of a domain of the account on no site (one registered through Spaicely then gets Spaicely DNS, which only answers once its nameservers are ours), such as MX and TXT for mail or a CNAME for a service on a subdomain. Types: A, AAAA, CNAME, TXT, MX, SRV, CAA. name is relative to the domain (empty or @ for the domain itself). Where the domain itself and www point stays with the site. delete takes record ids from domains.status. Ask the person first. Needs scope dns:write and a role that may change DNS.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            },
            "add": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "A",
                      "AAAA",
                      "CNAME",
                      "TXT",
                      "MX",
                      "SRV",
                      "CAA"
                    ]
                  },
                  "name": {
                    "type": "string"
                  },
                  "value": {
                    "type": "string"
                  },
                  "ttl": {
                    "type": "integer"
                  },
                  "priority": {
                    "type": "integer"
                  },
                  "weight": {
                    "type": "integer"
                  },
                  "port": {
                    "type": "integer"
                  },
                  "flags": {
                    "type": "integer"
                  },
                  "tag": {
                    "type": "string"
                  }
                },
                "required": [
                  "type",
                  "name",
                  "value"
                ]
              }
            },
            "delete": {
              "type": "array",
              "items": {
                "type": "integer"
              }
            }
          },
          "required": [
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": false,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "mail.forward.status",
        "title": "Mail forwarding of a domain",
        "scope": "mail:write",
        "public": false,
        "description": "Whether mail to a domain connected to a site is forwarded, its addresses (info@, or * for every address) and where each goes, with the state of each destination (pending until the address clicked the confirmation link we mailed it), and the mail held back in the last 30 days as possible spam (subject, sender, reason, id for mail.forward.release). Every address costs EUR 1.50 (GBP 1.30, USD 1.75) a month excl. VAT on the site's plan; Site allows 1, Pro 5, Shop 10. Needs scope mail:write and the owner or admin role.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            }
          },
          "required": [
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": true
        }
      },
      {
        "name": "mail.forward.enable",
        "title": "Switch on mail forwarding",
        "scope": "mail:write",
        "public": false,
        "description": "Lets Spaicely receive the mail of a domain connected to a paid site, so its addresses can be forwarded to mailboxes the person already has (mail.forward.add). Sets the records in Spaicely's DNS: MX to relay-mx.spaicely.net, a verification TXT, DKIM, the SPF include and DMARC if missing. When the domain already receives mail elsewhere (an MX of its own), that stops: the call refuses unless replace_existing_mail is true, so ask the person first. Spam and doubtful mail is held back, not forwarded. Needs scope mail:write and the owner or admin role.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            },
            "replace_existing_mail": {
              "type": "boolean"
            }
          },
          "required": [
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": false,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "mail.forward.disable",
        "title": "Switch off mail forwarding",
        "scope": "mail:write",
        "public": false,
        "description": "Stops forwarding for a domain: its addresses go, held-back mail is deleted, the MX and the other forwarding records leave Spaicely's DNS and the addresses are no longer billed. Mail to the domain is refused until it gets an MX elsewhere. Ask the person first. Needs scope mail:write and the owner or admin role.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            }
          },
          "required": [
            "domain"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "mail.forward.add",
        "title": "Forward an address",
        "scope": "mail:write",
        "public": false,
        "description": "Forwards an address of the domain (address: the part before the @, such as info, or * for every address without one of its own) to up to 5 mailboxes. Each new destination gets a mail from Spaicely with a link; nothing is forwarded there until it is clicked. Called again for an address that exists, it adds destinations. Each address costs EUR 1.50 (GBP 1.30, USD 1.75) a month excl. VAT on the site's plan, prorated; Site allows 1, Pro 5, Shop 10. Ask the person first. Needs mail.forward.enable before, scope mail:write and the owner or admin role.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            },
            "address": {
              "type": "string"
            },
            "destinations": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "maxItems": 5
            }
          },
          "required": [
            "domain",
            "address",
            "destinations"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "mail.forward.remove",
        "title": "Stop forwarding an address",
        "scope": "mail:write",
        "public": false,
        "description": "Removes a forwarded address of the domain (address: info, or *), or with destination only that one mailbox. Mail to a removed address is refused, unless * forwards it. The address is no longer billed. Ask the person first. Needs scope mail:write and the owner or admin role.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            },
            "address": {
              "type": "string"
            },
            "destination": {
              "type": "string"
            }
          },
          "required": [
            "domain",
            "address"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "mail.forward.release",
        "title": "Forward held-back mail",
        "scope": "mail:write",
        "public": false,
        "description": "Sends a message that was held back as possible spam on to its destinations after all (id from mail.forward.status). Only when the person recognises it as real mail; never for mail held as spam or scam unless they insist. Needs scope mail:write and the owner or admin role.",
        "input_schema": {
          "properties": {
            "domain": {
              "type": "string"
            },
            "message_id": {
              "type": "string"
            }
          },
          "required": [
            "domain",
            "message_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "scripts.list",
        "title": "List the forms of a site",
        "scope": "sites:read",
        "public": false,
        "description": "Lists the forms (scripts) of a site: form_id, kind (contact, signup, download or coupon), status (active, waiting_for_recipient, waiting_for_file, waiting_for_codes, disabled), the endpoint on scripts.spaicely.com, counts (for coupon: codes left and given out) and the HTML to put in a page. Needs scope sites:read.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            }
          },
          "required": [
            "site_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "scripts.enable",
        "title": "Add a form to a site",
        "scope": "scripts:write",
        "public": false,
        "description": "Adds a form to a site, handled by Spaicely on https://scripts.spaicely.com. kind contact mails every message to `recipient` (by default the person's own address); another address must confirm once by mail. kind signup keeps a list with double opt-in: the visitor confirms by mail, scripts.subscribers gives the list. kind download mails what a visitor fills in to `recipient` and gives the visitor a link to `file_path`, a file you deploy under _private/ (never on the CDN, so the form is the only way to it); see `delivery`. kind coupon does the same with a discount code: `coupon_code` for one code for everyone, `coupon_codes` for codes that each go out once, or `dated_codes` for codes with a window (a shared code per week, or unique codes that expire). With `price_cents` a download or coupon form is paid: the visitor pays on Stripe to the site owner and gets the link or code by mail. kind donation takes donations for the site owner on every plan: amount buttons and a free amount, paid on Stripe to the owner's own account (2% commission), a thank-you mail to the donor and a mail per donation to `recipient`; donors may show their name and message. `progress_url` in the answer gives the total, the number of donors and the names shown, as JSON open to any origin, for a progress bar towards `goal_cents`. Keep what you raise: no money goes back when a goal is not reached. Not for investments or loans. The answer has `html`: put that form in a page as it is and style it with the site's CSS; you may add fields (any name, not starting with _), the field email becomes the reply address. The form only works on the site's connected domain. Needs scope scripts:write and the role owner, admin or maintainer.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "kind": {
              "type": "string",
              "enum": [
                "contact",
                "signup",
                "download",
                "coupon",
                "donation"
              ]
            },
            "name": {
              "type": "string",
              "description": "What the person calls the form, such as Contact"
            },
            "recipient": {
              "type": "string",
              "description": "contact, download and coupon: the address that gets the messages"
            },
            "locale": {
              "type": "string",
              "enum": [
                "nl",
                "en",
                "de",
                "fr"
              ],
              "description": "Language of the mails and our pages"
            },
            "subject": {
              "type": "string",
              "description": "contact: subject of the mail"
            },
            "success_url": {
              "type": "string",
              "description": "Page on the site's own domain to show after sending"
            },
            "confirmed_url": {
              "type": "string",
              "description": "signup: page on the site's own domain after confirming"
            },
            "file_path": {
              "type": "string",
              "description": "download: the file in the site that visitors get, under _private/ (such as _private/guide.pdf); deploy it like any other file, it never goes to the CDN"
            },
            "delivery": {
              "type": "string",
              "enum": [
                "mail",
                "direct"
              ],
              "description": "download and coupon: mail (default) sends the link or code to the address filled in, which shows it is real; direct starts the download or shows the code right after sending (coupon: only codes everyone gets, coupon_code or shared codes; a paid form always mails)"
            },
            "keep_leads": {
              "type": "boolean",
              "description": "contact, download and coupon (free forms): also keep what visitors send, besides the mail, so the owner sees and exports it in the dashboard (scripts.leads). Off by default; kept leads go after a year"
            },
            "coupon_code": {
              "type": "string",
              "description": "coupon: one code every visitor gets, such as WELCOME10"
            },
            "coupon_codes": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "maxItems": 1000,
              "description": "coupon: codes that each go out once, added to the list (letters, digits, - and _, at most 40 characters); the recipient hears when 10 are left"
            },
            "dated_codes": {
              "type": "array",
              "maxItems": 1000,
              "description": "coupon: codes with a window, added to the list. shared true: everyone who asks during the window gets this code (a code per week, such as WEEK45 from 2026-11-03 to 2026-11-09); otherwise each goes out once and the mail says until when it is valid. Dates without a time are whole days in Dutch time. A shared code valid now goes before the unique codes, those before coupon_code",
              "items": {
                "type": "object",
                "required": [
                  "code"
                ],
                "properties": {
                  "code": {
                    "type": "string"
                  },
                  "valid_from": {
                    "type": "string",
                    "description": "Date (2026-11-03) or ISO 8601 time"
                  },
                  "valid_until": {
                    "type": "string",
                    "description": "Date (2026-11-09) or ISO 8601 time"
                  },
                  "shared": {
                    "type": "boolean"
                  }
                }
              }
            },
            "clear_coupons": {
              "type": "boolean",
              "description": "coupon: drop the codes not given out yet (and the shared ones) before adding"
            },
            "price_cents": {
              "type": "integer",
              "minimum": 0,
              "maximum": 1000000,
              "description": "download and coupon: price in cents including VAT (995 = EUR 9.95, at least 50); 0 makes the form free again. A paid form posts to our checkout: the visitor pays on Stripe, to the site owner's own Stripe account as in the web shop (plan Shop, 2% commission), and gets the link or code by mail after paying. The form asks the visitor to agree to delivery right away, which ends the right of withdrawal"
            },
            "donation_amounts": {
              "type": "array",
              "items": {
                "type": "integer",
                "minimum": 50
              },
              "maxItems": 6,
              "description": "donation: the amount buttons in cents (1000 = EUR 10); the visitor may also type an amount. Empty gives 10, 25 and 50 euros"
            },
            "donation_min_cents": {
              "type": "integer",
              "minimum": 0,
              "description": "donation: lowest amount in cents (default 100, at least 50; 0 = default)"
            },
            "donation_max_cents": {
              "type": "integer",
              "minimum": 0,
              "maximum": 1000000,
              "description": "donation: highest amount in cents (default and at most 1000000; 0 = default)"
            },
            "goal_cents": {
              "type": "integer",
              "minimum": 0,
              "description": "donation: the goal in cents for a progress bar; 0 removes it"
            },
            "ends_at": {
              "type": "string",
              "description": "donation: last day (2026-12-31, whole day in Dutch time) or ISO 8601 time; after it the form takes no more donations. Empty removes the end"
            }
          },
          "required": [
            "site_id",
            "kind",
            "name"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "scripts.configure",
        "title": "Change a form",
        "scope": "scripts:write",
        "public": false,
        "description": "Changes a form made with scripts.enable: only the settings you pass. `enabled` false switches it off (visitors then get an error), true switches it on again. A new recipient must confirm by mail first. For a coupon form, coupon_codes and dated_codes add codes to its list. price_cents sets or (with 0) removes a price; a paid form gets a new endpoint, so put the new `html` in the page. Needs scope scripts:write and the role owner, admin or maintainer.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "form_id": {
              "type": "string"
            },
            "enabled": {
              "type": "boolean"
            },
            "name": {
              "type": "string",
              "description": "What the person calls the form, such as Contact"
            },
            "recipient": {
              "type": "string",
              "description": "contact, download and coupon: the address that gets the messages"
            },
            "locale": {
              "type": "string",
              "enum": [
                "nl",
                "en",
                "de",
                "fr"
              ],
              "description": "Language of the mails and our pages"
            },
            "subject": {
              "type": "string",
              "description": "contact: subject of the mail"
            },
            "success_url": {
              "type": "string",
              "description": "Page on the site's own domain to show after sending"
            },
            "confirmed_url": {
              "type": "string",
              "description": "signup: page on the site's own domain after confirming"
            },
            "file_path": {
              "type": "string",
              "description": "download: the file in the site that visitors get, under _private/ (such as _private/guide.pdf); deploy it like any other file, it never goes to the CDN"
            },
            "delivery": {
              "type": "string",
              "enum": [
                "mail",
                "direct"
              ],
              "description": "download and coupon: mail (default) sends the link or code to the address filled in, which shows it is real; direct starts the download or shows the code right after sending (coupon: only codes everyone gets, coupon_code or shared codes; a paid form always mails)"
            },
            "keep_leads": {
              "type": "boolean",
              "description": "contact, download and coupon (free forms): also keep what visitors send, besides the mail, so the owner sees and exports it in the dashboard (scripts.leads). Off by default; kept leads go after a year"
            },
            "coupon_code": {
              "type": "string",
              "description": "coupon: one code every visitor gets, such as WELCOME10"
            },
            "coupon_codes": {
              "type": "array",
              "items": {
                "type": "string"
              },
              "maxItems": 1000,
              "description": "coupon: codes that each go out once, added to the list (letters, digits, - and _, at most 40 characters); the recipient hears when 10 are left"
            },
            "dated_codes": {
              "type": "array",
              "maxItems": 1000,
              "description": "coupon: codes with a window, added to the list. shared true: everyone who asks during the window gets this code (a code per week, such as WEEK45 from 2026-11-03 to 2026-11-09); otherwise each goes out once and the mail says until when it is valid. Dates without a time are whole days in Dutch time. A shared code valid now goes before the unique codes, those before coupon_code",
              "items": {
                "type": "object",
                "required": [
                  "code"
                ],
                "properties": {
                  "code": {
                    "type": "string"
                  },
                  "valid_from": {
                    "type": "string",
                    "description": "Date (2026-11-03) or ISO 8601 time"
                  },
                  "valid_until": {
                    "type": "string",
                    "description": "Date (2026-11-09) or ISO 8601 time"
                  },
                  "shared": {
                    "type": "boolean"
                  }
                }
              }
            },
            "clear_coupons": {
              "type": "boolean",
              "description": "coupon: drop the codes not given out yet (and the shared ones) before adding"
            },
            "price_cents": {
              "type": "integer",
              "minimum": 0,
              "maximum": 1000000,
              "description": "download and coupon: price in cents including VAT (995 = EUR 9.95, at least 50); 0 makes the form free again. A paid form posts to our checkout: the visitor pays on Stripe, to the site owner's own Stripe account as in the web shop (plan Shop, 2% commission), and gets the link or code by mail after paying. The form asks the visitor to agree to delivery right away, which ends the right of withdrawal"
            },
            "donation_amounts": {
              "type": "array",
              "items": {
                "type": "integer",
                "minimum": 50
              },
              "maxItems": 6,
              "description": "donation: the amount buttons in cents (1000 = EUR 10); the visitor may also type an amount. Empty gives 10, 25 and 50 euros"
            },
            "donation_min_cents": {
              "type": "integer",
              "minimum": 0,
              "description": "donation: lowest amount in cents (default 100, at least 50; 0 = default)"
            },
            "donation_max_cents": {
              "type": "integer",
              "minimum": 0,
              "maximum": 1000000,
              "description": "donation: highest amount in cents (default and at most 1000000; 0 = default)"
            },
            "goal_cents": {
              "type": "integer",
              "minimum": 0,
              "description": "donation: the goal in cents for a progress bar; 0 removes it"
            },
            "ends_at": {
              "type": "string",
              "description": "donation: last day (2026-12-31, whole day in Dutch time) or ISO 8601 time; after it the form takes no more donations. Empty removes the end"
            }
          },
          "required": [
            "site_id",
            "form_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "scripts.subscribers",
        "title": "List the subscribers of a signup form",
        "scope": "scripts:write",
        "public": false,
        "description": "Lists who signed up with a signup form: email, name, when they signed up and confirmed, and an unsubscribe_url to put in every mail sent to them. Only confirmed people who did not unsubscribe, unless all is true. Needs scope scripts:write and the role owner, admin or maintainer.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "form_id": {
              "type": "string"
            },
            "all": {
              "type": "boolean"
            }
          },
          "required": [
            "site_id",
            "form_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "scripts.subscribers.update",
        "title": "Unsubscribe or remove a subscriber",
        "scope": "scripts:write",
        "public": false,
        "description": "For a signup form: `action` unsubscribe keeps the address on the list as unsubscribed, so it gets no more mail; remove erases everything about them, for instance when they ask to be forgotten. Ask the person first. Needs scope scripts:write and the role owner, admin or maintainer.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "form_id": {
              "type": "string"
            },
            "email": {
              "type": "string"
            },
            "action": {
              "type": "string",
              "enum": [
                "unsubscribe",
                "remove"
              ]
            }
          },
          "required": [
            "site_id",
            "form_id",
            "email",
            "action"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "scripts.leads",
        "title": "List the leads of a form",
        "scope": "scripts:write",
        "public": false,
        "description": "Lists what visitors sent through a contact, download or coupon form that keeps leads (keep_leads true in scripts.configure): when, the fields in the order of the form, the page and the code a coupon form gave. Newest first, at most 500; `before` (an id) pages back. Leads go after a year. Needs scope scripts:write and the role owner, admin or maintainer.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "form_id": {
              "type": "string"
            },
            "before": {
              "type": "integer",
              "description": "Only leads older than this id"
            }
          },
          "required": [
            "site_id",
            "form_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "scripts.leads.remove",
        "title": "Remove leads",
        "scope": "scripts:write",
        "public": false,
        "description": "Removes leads a form kept (scripts.leads): the ones in `ids`, or every lead with `all: true`, for instance when a visitor asks to be forgotten. The mails already sent stay where they are. This cannot be undone: ask the person first. Needs scope scripts:write and the role owner, admin or maintainer.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "form_id": {
              "type": "string"
            },
            "ids": {
              "type": "array",
              "items": {
                "type": "integer"
              },
              "description": "Lead ids from scripts.leads"
            },
            "all": {
              "type": "boolean"
            }
          },
          "required": [
            "site_id",
            "form_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "scripts.remove",
        "title": "Remove a form",
        "scope": "scripts:write",
        "public": false,
        "description": "Removes a form made with scripts.enable, with everything it kept: signups, leads and codes. Visitors who still post to it get an error, so take the form out of the pages first. To stop it for a while, use scripts.configure with enabled false instead. This cannot be undone: ask the person first. Needs scope scripts:write and the role owner, admin or maintainer.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "form_id": {
              "type": "string"
            }
          },
          "required": [
            "site_id",
            "form_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": false,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "shop.setup",
        "title": "Set up the shop checkout of a site",
        "scope": "scripts:write",
        "public": false,
        "description": "Sets up checkout for a web shop on a site with the Shop plan. Payments go through Stripe Checkout on the site owner's own Stripe account, so the owner is the seller; Spaicely keeps a commission (commission_percent). Products and prices come from a JSON file in the live version of the site (`products_path`, default data/products.json): {\"products\":[{\"id\":\"mug-blue\",\"name\":\"Blue mug\",\"price\":1495,\"description\":\"...\",\"image\":\"/img/mug.jpg\"}],\"shipping\":[{\"id\":\"nl\",\"name\":\"Shipping in the Netherlands\",\"price\":495,\"countries\":[\"NL\"]}]} with prices in cents including VAT; shipping is optional. Keep the cart in the browser (localStorage) and POST {items:[{id,quantity}], shipping} as JSON to `endpoint` with fetch, then go to the `url` in the answer; or use the form in `html` as a buy button. Only the site's connected domain and its address may post. `status` says what is missing: waiting_for_plan, waiting_for_billing_details, waiting_for_stripe (give the person `connect_url`, where they make a Stripe account or link the one they have; `stripe_needs` says what Stripe still asks of them), waiting_for_products (see `problems`), or active. Call again after publishing the products file. Needs scope scripts:write and the role owner, admin or maintainer.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "products_path": {
              "type": "string",
              "description": "Path of the products file in the site, default data/products.json"
            },
            "success_url": {
              "type": "string",
              "description": "https page on the site's own domain after paying"
            },
            "cancel_url": {
              "type": "string",
              "description": "https page on the site's own domain when the buyer goes back"
            },
            "locale": {
              "type": "string",
              "enum": [
                "nl",
                "en",
                "de",
                "fr"
              ],
              "description": "Language of Stripe Checkout and the order mails"
            },
            "notify_email": {
              "type": "string",
              "description": "Where order mails go; default the owners of the account"
            },
            "enabled": {
              "type": "boolean"
            }
          },
          "required": [
            "site_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "shop.orders",
        "title": "List the orders of a site's shop",
        "scope": "sites:read",
        "public": false,
        "description": "Lists the latest paid orders (at most 50) of the shop of a site: when, buyer name and email, shipping address, lines, total in cents and Spaicely's commission. Refunds and payouts are in the owner's own Stripe dashboard. Needs scope sites:read; the buyer's details only for the role owner, admin or maintainer.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            }
          },
          "required": [
            "site_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "access.list",
        "title": "List who works on a site",
        "scope": "sites:read",
        "public": false,
        "description": "Lists the owner account, the paying account and everyone with a role on a site: people (name, email) and partner accounts, with role, whether they may publish and their file rules (ADR 0006: actions on glob paths, optionally limited to marked regions). Also open handovers. Roles: admin, maintainer, designer, editor, viewer. Needs scope sites:read.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            }
          },
          "required": [
            "site_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": true
        }
      },
      {
        "name": "access.update",
        "title": "Give someone a role on a site, change it or take it away",
        "scope": "access:manage",
        "public": false,
        "description": "Gives a person a role on a site, changes it, or with role `none` takes them off. The person needs a Spaicely account with that email address; otherwise ask them to make one at app.spaicely.com. Roles: admin (everything but owner matters), maintainer, designer, editor, viewer. `can_publish` lets a maintainer or editor publish. `rules` limits a designer or editor to files, as in ADR 0006: [{\"actions\": [\"create\",\"update\",\"delete\"], \"paths\": [\"blog/**\"], \"regions\": [\"news\"]}]; regions are blocks marked <!-- spaicely:edit news --> in HTML. Ask the person first. Only the owner may do this. Needs scope access:manage.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "email": {
              "type": "string"
            },
            "role": {
              "type": "string",
              "enum": [
                "admin",
                "maintainer",
                "designer",
                "editor",
                "viewer",
                "none"
              ]
            },
            "can_publish": {
              "type": "boolean"
            },
            "rules": {
              "type": "array",
              "items": {
                "type": "object"
              }
            }
          },
          "required": [
            "site_id",
            "email",
            "role"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": true,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.handover",
        "title": "Hand a site over to the customer",
        "scope": "partner:manage",
        "public": false,
        "description": "For a partner account that owns a site: mails the customer a link, valid for 14 days, to take the site onto their own account. `role` is what the partner keeps afterwards (the customer may narrow it or drop it; omit it to stop working on the site); `payer` is `customer` or `partner`. The site moves only when the customer accepts. Ask the person first. Needs scope partner:manage.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            },
            "email": {
              "type": "string"
            },
            "role": {
              "type": "string",
              "enum": [
                "admin",
                "maintainer",
                "designer",
                "editor",
                "viewer"
              ]
            },
            "payer": {
              "type": "string",
              "enum": [
                "customer",
                "partner"
              ]
            }
          },
          "required": [
            "site_id",
            "email"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": false,
          "openWorldHint": true,
          "readOnlyHint": false
        }
      },
      {
        "name": "sites.handover.revoke",
        "title": "Take back a handover",
        "scope": "partner:manage",
        "public": false,
        "description": "Takes back a handover sent with sites.handover that the customer has not accepted yet: the link in their mail stops working and the site stays with the partner. Needs scope partner:manage.",
        "input_schema": {
          "properties": {
            "site_id": {
              "type": "integer"
            }
          },
          "required": [
            "site_id"
          ],
          "type": "object"
        },
        "annotations": {
          "destructiveHint": false,
          "idempotentHint": true,
          "openWorldHint": false,
          "readOnlyHint": false
        }
      }
    ],
    "prompts": [
      {
        "name": "domain_brainstorm",
        "title": "Brainstorm a domain name",
        "description": "Think of domain names for a business, club or project and check which are free."
      },
      {
        "name": "new_site",
        "title": "Build and publish a new site",
        "description": "Make a website for a business, club or project with Spaicely: plan, build, publish and connect a domain."
      },
      {
        "name": "move_site",
        "title": "Move an existing site to Spaicely",
        "description": "Rebuild a site that is hosted elsewhere (WordPress, Wix, Squarespace) on Spaicely, keeping its pages, addresses and mail."
      },
      {
        "name": "launch_check",
        "title": "Check a site before launch",
        "description": "Go over a Spaicely site before or after launch: missing pages and tags, domain, hosting, forms."
      }
    ]
  },
  "scopes": {
    "account:read": "See your name, your account and the list of your sites",
    "sites:read": "View your sites, versions and previews",
    "content:write": "Change files on your sites, within your role",
    "sites:publish": "Put changes live and roll them back",
    "scripts:write": "Set up forms, the shopping cart and other scripts",
    "dns:write": "Change the DNS records of your domains",
    "mail:write": "Forward the mail of your domains (paid per address per month)",
    "domains:read": "Search domain names and check their status",
    "domains:buy": "Register, transfer and renew domain names (always with your confirmation)",
    "billing:write": "Start or change a plan (always with your confirmation)",
    "access:manage": "Invite people and change or remove their role",
    "partner:manage": "Hand over sites and manage clients (for web designers)",
    "handover:accept": "Accept a handover from a web designer"
  },
  "http": [
    {
      "method": "GET",
      "url": "https://app.spaicely.com/api/domains/search/{names}/{extensions}",
      "auth": "none",
      "summary": "Quick check (DNS only) whether domains are free: comma-separated names or full domains, then optional extensions, at most 50 domains. The query form ?names=&extensions= works too. Answers status per domain (taken, likely_available with a reason, unknown, invalid, unsupported), a register_url or transfer_url, and a verify_url for the deep check. CORS open; 30 requests a minute and 300 an hour per IP address."
    },
    {
      "method": "GET",
      "url": "https://app.spaicely.com/api/domains/verify/{names}/{extensions}",
      "auth": "none",
      "summary": "Deep check of at most 10 domains: DNS, then our registrar (checked_via registrar) or else the registry over RDAP. Answers available or taken where one answers, otherwise likely_available with reason rdap_rate_limited, rdap_unavailable or no_rdap. A premium name has premium: true and price: our price in euro cents before VAT for the extension's term."
    },
    {
      "method": "GET",
      "url": "https://app.spaicely.com/api/domains/extensions",
      "auth": "none",
      "summary": "The extensions people ask for most (extensions, the default for a bare name), all 718 Spaicely sells (all), the most domains per request, and which extensions a deep check can settle (verifiable), and a price class per extension (price_classes: 0 cheap to 4 dear), and our prices (prices: per extension create, renew and transfer in euro cents before VAT, each for the whole term paid up front: months for create, renew_months and transfer_months, 12 for most and 24 for .ai; transfer_months null means a move adds no term, as for .nl, and then often costs nothing; add VAT where it applies), and euro_rates: euros per GBP and per USD, the rate at which an account in the United Kingdom (pounds) or outside Europe (dollars) pays those prices."
    },
    {
      "method": "PUT",
      "url": "https://app.spaicely.com/uploads/{token}",
      "auth": "one-time URL",
      "summary": "Upload a whole site as one tar or tar.gz (index.html at the root, plain files only, at most 200 MB) to the URL that sites.upload_url returned. It works once, for 10 minutes. POST works too. The answer is the new version; put it live with sites.publish."
    },
    {
      "method": "POST",
      "url": "https://scripts.spaicely.com/f/{form_id}",
      "auth": "Origin: the site's connected domain",
      "summary": "A contact form made with scripts.enable posts here: urlencoded from a plain HTML form, or JSON with fetch. Every field not starting with _ goes to the confirmed recipient by mail; the field email becomes the reply address; _hp is the honeypot and must stay empty. A plain form gets a redirect to success_url or a page of ours, a JSON request gets {\"ok\": true} or {\"ok\": false, \"error\": ...} (unknown_form, origin_not_allowed, form_inactive, invalid, rate_limited, failed). At most 30 fields, 64 KB; 10 posts per visitor per 10 minutes and 100 per form per hour."
    },
    {
      "method": "POST",
      "url": "https://scripts.spaicely.com/s/{form_id}",
      "auth": "Origin: the site's connected domain",
      "summary": "A signup form made with scripts.enable posts here, with email and optionally name. The visitor gets a mail to confirm (double opt-in); confirmed_url is where the link ends. The answer is the same whether the address was on the list or not. scripts.subscribers gives the list with an unsubscribe_url each."
    },
    {
      "method": "POST",
      "url": "https://scripts.spaicely.com/l/{form_id}",
      "auth": "Origin: the site's connected domain",
      "summary": "A download form made with scripts.enable posts here, with email and any other fields. Everything not starting with _ goes to the confirmed recipient by mail, as with a contact form, and the visitor gets a link to the form's file_path (under _private/, never on the CDN). delivery mail: the link goes to the address filled in, works 7 days, and a plain form gets success_url or a page of ours; delivery direct: a plain form is redirected to the link, a JSON request gets {\"ok\": true, \"download_url\": ...} to open, and the link works for an hour. A link works at most 5 times."
    },
    {
      "method": "POST",
      "url": "https://scripts.spaicely.com/k/{form_id}",
      "auth": "Origin: the site's connected domain",
      "summary": "A coupon form made with scripts.enable posts here, with email and any other fields. Everything not starting with _ goes to the confirmed recipient by mail, with the code the visitor got. The form has one code for everyone (coupon_code) and/or a list (coupon_codes, dated_codes): a shared code valid now goes first, then unique codes valid now (each once; the mail says until when it is valid), then coupon_code. delivery mail: the code goes to the address filled in, and a plain form gets success_url or a page of ours; delivery direct (only codes everyone gets): a JSON request gets {\"ok\": true, \"code\": ...}, a plain form a page with the code. The recipient gets a mail when 10 unique codes are left and when they ran out; without a code valid now the form answers as not active. A paid form does not post here but to /forms/{form_id}/checkout."
    },
    {
      "method": "GET",
      "url": "https://{site domain}/_download/{token}",
      "auth": "the link from a download form",
      "summary": "The link a visitor gets from a download form, on the site's own domain; it never shows where the file lives. It shows a page that asks for the file by itself (so a mail scanner opening the link uses up nothing) and sends the file from the live version of the site, under its own name and type. Expired, used up, or the file no longer live: 404. A site cannot have files under _download/."
    },
    {
      "method": "POST",
      "url": "https://app.spaicely.com/forms/{form_id}/checkout",
      "auth": "Origin: the site's connected domain or its address",
      "summary": "A download or coupon form with price_cents posts here instead of to the scripts service: a plain form (urlencoded) or fetch as JSON with email, start_now and any other fields. start_now must be 1: the visitor agrees to delivery right away, which ends the right of withdrawal for digital content. A form is redirected (303) to Stripe Checkout, JSON gets {\"url\": ...}. The visitor pays the site owner's Stripe account (2% commission, as the web shop); after paying the link or code goes to the address by mail and the details to the recipient. Errors: {\"error\": ..., \"message\": ...} with invalid_email, start_now_required, not_ready, origin (403) or too_many. A donation form posts here too, without start_now but with amount (one of its buttons, in euros) or amount_other (typed, \"12,50\" or \"12.50\"), optional name and message, and show_name 1 to show name and message on the site; an amount outside its range gives the error amount. The donor gets a thank-you mail, the recipient one per donation."
    },
    {
      "method": "GET",
      "url": "https://app.spaicely.com/forms/{form_id}/progress",
      "auth": "none, open to every origin",
      "summary": "What a donation form raised, for a progress bar on the site: {\"raised_cents\", \"donors\", \"goal_cents\", \"ends_at\", \"open\", \"donations\": [{\"name\", \"message\", \"at\"}]} with paid donations only and the last 20 names and messages donors chose to show. Cached for a minute; other forms give 404."
    },
    {
      "method": "POST",
      "url": "https://app.spaicely.com/shop/{shop_id}/checkout",
      "auth": "Origin: the site's connected domain or its address",
      "summary": "A web shop made with shop.setup sends an order here, from a plain form (a buy button, urlencoded items[0][id], items[0][quantity], shipping) or with fetch as JSON {\"items\": [{\"id\": ..., \"quantity\": ...}], \"shipping\": ...}. Products and prices come from data/products.json in the live version, never from the request: at most 50 lines, 1 to 99 each; shipping is the id of a shipping option and needed when the file has any. A form is redirected (303) to Stripe Checkout, JSON gets {\"url\": ...} to open. Errors: {\"error\": ..., \"message\": ...} with invalid_items, invalid_shipping, shipping_required, not_ready, origin (403) or too_many (30 per visitor per 10 minutes). The buyer pays the shop owner's Stripe account; shop.orders lists the paid orders."
    },
    {
      "method": "POST",
      "url": "https://mcp.spaicely.com/mcp",
      "auth": "OAuth 2.1 bearer",
      "summary": "MCP server for signed-in clients (Streamable HTTP, stateless; GET and DELETE answer 405). Without a token it answers 401 with WWW-Authenticate pointing to the protected resource metadata."
    },
    {
      "method": "POST",
      "url": "https://mcp.spaicely.com/mcp/public",
      "auth": "none",
      "summary": "MCP server without an account: domains.search and the prompt domain_brainstorm."
    },
    {
      "method": "GET",
      "url": "https://mcp.spaicely.com/.well-known/mcp/server-card.json",
      "auth": "none",
      "summary": "MCP server card: name, endpoints, authentication and tools. Also at /mcp/server-card."
    },
    {
      "method": "GET",
      "url": "https://mcp.spaicely.com/.well-known/oauth-protected-resource/mcp",
      "auth": "none",
      "summary": "Protected resource metadata (RFC 9728) for the MCP server: which authorization server and scopes."
    },
    {
      "method": "GET",
      "url": "https://app.spaicely.com/.well-known/oauth-authorization-server",
      "auth": "none",
      "summary": "Authorization server metadata (RFC 8414)."
    },
    {
      "method": "POST",
      "url": "https://app.spaicely.com/oauth/register",
      "auth": "none",
      "summary": "Dynamic client registration (RFC 7591). Every client is public: no secret, PKCE S256 required. Instead, a client may pass the https URL of its client ID metadata document as client_id."
    },
    {
      "method": "GET",
      "url": "https://app.spaicely.com/oauth/authorize",
      "auth": "browser session",
      "summary": "Authorization code flow with PKCE S256. The person signs in and chooses the account, the sites and the scopes on the consent screen. The resource parameter, when given, must be https://mcp.spaicely.com/mcp."
    },
    {
      "method": "POST",
      "url": "https://app.spaicely.com/oauth/token",
      "auth": "client_id + PKCE",
      "summary": "Exchanges the code, or a refresh token, for an access token (1 hour). Refresh tokens rotate on every use and expire after 30 days unused."
    },
    {
      "method": "POST",
      "url": "https://app.spaicely.com/oauth/revoke",
      "auth": "client_id",
      "summary": "Revokes an access or refresh token (RFC 7009)."
    }
  ]
}
