{
  "openapi": "3.0.3",
  "info": {
    "title": "Simple Host API",
    "description": "Static website hosting with a light per-site backend. Deploy a site with one API call \u2014 inline JSON files (LLM-friendly) or a tar.gz/zip archive \u2014 and each site gets shared JSON state (with atomic ops), append-only collections, optional password (view-lock) protection, and starter templates. Sign-in is a 6-digit email code that returns an API key, sent as the `X-API-Key` header.",
    "version": "2.0.0"
  },
  "servers": [
    {
      "url": "https://simple-host.app",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Auth",
      "description": "Email-code sign-in"
    },
    {
      "name": "Deploy",
      "description": "Create/update a site (JSON files or archive)"
    },
    {
      "name": "Sites",
      "description": "List, versions, rollback, delete"
    },
    {
      "name": "State",
      "description": "Per-site shared JSON store (public, Origin-gated)"
    },
    {
      "name": "Collections",
      "description": "Append-only per-site lists"
    },
    {
      "name": "Private pages",
      "description": "Password (view-lock) protection"
    },
    {
      "name": "Custom domains",
      "description": "Bind one custom domain per site (CNAME + on-demand TLS)"
    },
    {
      "name": "Analytics",
      "description": "Per-site visitor analytics (server-side, owner-scoped)"
    },
    {
      "name": "Templates",
      "description": "Starter templates"
    },
    {
      "name": "AI",
      "description": "Generate a site from a prompt"
    },
    {
      "name": "Health"
    }
  ],
  "paths": {
    "/v1/skills": {
      "get": {
        "operationId": "listSkills",
        "summary": "Skills hub catalog \u2014 discover the bundled agent skills",
        "description": "Public catalog of the skills this server bundles (name + description + fetch URL), so any agent can search them over HTTP. No auth.",
        "responses": {
          "200": {
            "description": "Skill catalog",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "plugin": {
                      "type": "string"
                    },
                    "version": {
                      "type": "string"
                    },
                    "count": {
                      "type": "integer"
                    },
                    "skills": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "name": {
                            "type": "string"
                          },
                          "description": {
                            "type": "string"
                          },
                          "url": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/skills/{name}": {
      "get": {
        "operationId": "getSkill",
        "summary": "Fetch one skill's SKILL.md (markdown)",
        "description": "Returns the raw SKILL.md for the named skill. Public, no auth.",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The skill's SKILL.md",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "404": {
            "description": "No such skill"
          }
        }
      }
    },
    "/v1/skills/{name}/references/{file}": {
      "get": {
        "operationId": "getSkillReference",
        "summary": "Fetch one of a skill's reference documents (markdown)",
        "description": "A skill's SKILL.md routes to reference documents under its references/ directory. A folder install has them on disk; install methods that fetch only SKILL.md by URL do not, so SKILL.md cites each reference by absolute URL as well and this endpoint serves it. Public, no auth.",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "file",
            "in": "path",
            "required": true,
            "description": "The reference filename, e.g. backend.md",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The reference document",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "Invalid skill or reference name"
          },
          "404": {
            "description": "No such reference"
          }
        }
      }
    },
    "/v1/auth": {
      "post": {
        "operationId": "postAuth",
        "summary": "Request a sign-in code",
        "description": "Emails a 6-digit code (and a magic link) to the address. No user row is created here \u2014 the account is created lazily on the first successful verify, so this can't be used to enumerate users.",
        "tags": [
          "Auth"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "example": "you@example.com"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Code sent",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string"
                    },
                    "email": {
                      "type": "string"
                    },
                    "expires_in_seconds": {
                      "type": "integer",
                      "example": 900
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/v1/auth/verify": {
      "post": {
        "operationId": "postAuthVerify",
        "summary": "Verify the code and get an API key",
        "description": "Exchange `{email, code}` (CLI/agent) or a magic-link `{token}` (browser) for an API key. The first successful verify creates the account. The code expires in 15 minutes and allows 3 attempts.",
        "tags": [
          "Auth"
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "code": {
                    "type": "string",
                    "example": "123456"
                  },
                  "token": {
                    "type": "string",
                    "description": "magic-link token (alternative to email+code)"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Authenticated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthResponse"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "description": "Invalid or expired code",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/v1/me": {
      "get": {
        "operationId": "getMe",
        "summary": "Current user",
        "tags": [
          "Auth"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Current user",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "format": "uuid"
                    },
                    "username": {
                      "type": "string"
                    },
                    "is_admin": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/v1/sites": {
      "get": {
        "operationId": "getSites",
        "summary": "List your sites",
        "description": "Returns the authenticated user's sites. Admins see all sites.",
        "tags": [
          "Sites"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "List of sites",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Site"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/v1/sites/{sitename}/files": {
      "post": {
        "operationId": "postSitesBySitenameFiles",
        "summary": "Deploy a site from inline JSON files",
        "description": "The LLM-friendly deploy path \u2014 send every file inline in one request, no archiving. `index.html` is required. Relative paths only (`..`/absolute are rejected); secret files (`.env`, `.git/*`, `id_rsa`) are dropped and script extensions (`.sh .py .php \u2026`) are rejected. Use PUT to update an existing site.",
        "tags": [
          "Deploy"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FilesBody"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Site created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          }
        }
      },
      "put": {
        "operationId": "putSitesBySitenameFiles",
        "summary": "Update a site from inline JSON files",
        "description": "Same as POST, but for an existing site you own. Creates a new version and activates it.",
        "tags": [
          "Deploy"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/FilesBody"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Site updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}": {
      "post": {
        "operationId": "postSitesBySitename",
        "summary": "Create a site from an archive",
        "description": "Upload a `.tar.gz` or `.zip` (for framework builds, binary assets, large sites). Max 100 MB.",
        "tags": [
          "Deploy"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/gzip": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            },
            "application/zip": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Site created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "409": {
            "$ref": "#/components/responses/Conflict"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          }
        }
      },
      "put": {
        "operationId": "putSitesBySitename",
        "summary": "Update a site from an archive",
        "description": "Upload a new version of an existing site you own. Creates a new version and activates it.",
        "tags": [
          "Deploy"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/gzip": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            },
            "application/zip": {
              "schema": {
                "type": "string",
                "format": "binary"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Site updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SiteWithNote"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          }
        }
      },
      "delete": {
        "operationId": "deleteSitesBySitename",
        "summary": "Delete your site",
        "description": "Deletes the site you own \u2014 all versions and files from disk.",
        "tags": [
          "Sites"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Site deleted"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/versions": {
      "get": {
        "operationId": "getSitesBySitenameVersions",
        "summary": "List a site's versions",
        "tags": [
          "Sites"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "description": "Versions (newest first)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "version_number": {
                        "type": "integer"
                      },
                      "created_at": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "is_active": {
                        "type": "boolean"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/active-version": {
      "put": {
        "operationId": "putSitesBySitenameActiveVersion",
        "summary": "Roll back to a version",
        "description": "Re-point the live site at an existing version number.",
        "tags": [
          "Sites"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "version_number"
                ],
                "properties": {
                  "version_number": {
                    "type": "integer",
                    "example": 2
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Active version changed"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/visibility": {
      "put": {
        "operationId": "putSitesBySitenameVisibility",
        "summary": "Set showcase visibility",
        "description": "Set whether this site is listed on the owner's public showcase at sites.<domain>/<handle>. `public` = listed on the showcase; `unlisted` = still reachable by direct link but hidden from the showcase. Owner only (API key).",
        "tags": [
          "Sites"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "visibility"
                ],
                "properties": {
                  "visibility": {
                    "type": "string",
                    "enum": [
                      "public",
                      "unlisted"
                    ],
                    "example": "unlisted"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Visibility updated"
          },
          "400": {
            "description": "Invalid visibility value"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/analytics": {
      "get": {
        "operationId": "getSitesBySitenameAnalytics",
        "summary": "Get per-site visitor analytics",
        "description": "Owner-scoped daily view and visitor counts derived server-side from the access log (no client beacon). `visitors` are distinct hashed IPs per UTC day \u2014 salt rotates daily, so a returning-next-day visitor counts once per day in the range total. `daily` is zero-filled dense for the requested window (oldest \u2192 newest). Owner only (API key).",
        "tags": [
          "Analytics"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "name": "days",
            "in": "query",
            "required": false,
            "description": "Number of UTC days to include (default 30, clamp 1..365)",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 30
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Analytics for the site",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "range_days": {
                      "type": "integer",
                      "example": 30
                    },
                    "totals": {
                      "type": "object",
                      "properties": {
                        "views": {
                          "type": "integer",
                          "example": 1234
                        },
                        "visitors": {
                          "type": "integer",
                          "example": 456
                        }
                      }
                    },
                    "daily": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "day": {
                            "type": "string",
                            "format": "date",
                            "example": "2026-07-11"
                          },
                          "views": {
                            "type": "integer",
                            "example": 42
                          },
                          "visitors": {
                            "type": "integer",
                            "example": 30
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/state": {
      "get": {
        "operationId": "getSitesBySitenameState",
        "summary": "Read per-site JSON state",
        "description": "Returns the JSON blob for this site (`null` if unset). PUBLIC store \u2014 no API key; the server checks `Origin`/`Referer` and only accepts calls from the site's own page (`https://{sitename}.simple-host.app`). The response carries an `ETag`; send `If-None-Match: <etag>` to get a `304` when nothing changed (cheap polling). Never store secrets here.",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current state (arbitrary JSON, `null` if unset)",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "304": {
            "description": "Not modified (matched If-None-Match)"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      },
      "put": {
        "operationId": "putSitesBySitenameState",
        "summary": "Replace per-site JSON state",
        "description": "Overwrites the whole blob (\u2264 1 MB), last-write-wins. Optionally pass `If-Match: <etag>` for optimistic concurrency (returns 412 on conflict). Origin-gated, no API key.",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "name": "If-Match",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {}
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved; echoes the value",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          },
          "412": {
            "description": "ETag did not match (If-Match)"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          }
        }
      },
      "patch": {
        "operationId": "patchSitesBySitenameState",
        "summary": "Atomically update per-site state",
        "description": "Apply one or more atomic operations so concurrent writers never clobber. Origin-gated, no API key. Ops \u2014 `set` (path,value), `inc` (path,by), `append` (path,value), `remove` (path), `removeWhere` (path,match).",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ops"
                ],
                "properties": {
                  "ops": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "op": {
                          "type": "string",
                          "enum": [
                            "set",
                            "inc",
                            "append",
                            "remove",
                            "removeWhere"
                          ]
                        },
                        "path": {
                          "type": "string",
                          "example": "counters.visits"
                        },
                        "value": {},
                        "by": {
                          "type": "number"
                        },
                        "match": {
                          "type": "object"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "ops": [
                    {
                      "op": "inc",
                      "path": "count",
                      "by": 1
                    },
                    {
                      "op": "append",
                      "path": "items",
                      "value": {
                        "id": "x"
                      }
                    }
                  ]
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "New state",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      }
    },
    "/v1/sites/{sitename}/collections/{coll}": {
      "post": {
        "operationId": "postSitesBySitenameCollectionsByColl",
        "summary": "Append an item to a collection",
        "description": "Append-only list (O(1) insert). For signups, RSVPs, submissions. Origin-gated, no API key. Each item \u2264 64 KB.",
        "tags": [
          "Collections"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/coll"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Item appended"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      },
      "get": {
        "operationId": "getSitesBySitenameCollectionsByColl",
        "summary": "List items in a collection",
        "description": "Newest-first, paginated. Origin-gated, no API key.",
        "tags": [
          "Collections"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/coll"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            }
          },
          {
            "name": "before",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "cursor for the next page"
          }
        ],
        "responses": {
          "200": {
            "description": "Items",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      }
    },
    "/v1/u/{handle}/sites/{sitename}/state": {
      "get": {
        "operationId": "getSiteStateByHandle",
        "summary": "Read per-site JSON state (v3, by handle)",
        "description": "Returns the JSON blob for this site (`null` if unset). PUBLIC store \u2014 no API key; the server checks `Origin`/`Referer` and only accepts calls from the site's own page (`https://{sitename}.simple-host.app`). The response carries an `ETag`; send `If-None-Match: <etag>` to get a `304` when nothing changed (cheap polling). Never store secrets here. v3 user-scoped, path-model-canonical variant: resolves the site by (handle -> user_id) + (user_id, name).",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "the site owner's URL-safe handle"
          },
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "name": "If-None-Match",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current state (arbitrary JSON, `null` if unset)",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "304": {
            "description": "Not modified (matched If-None-Match)"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      },
      "put": {
        "operationId": "putSiteStateByHandle",
        "summary": "Replace per-site JSON state (v3, by handle)",
        "description": "Overwrites the whole blob (\u2264 1 MB), last-write-wins. Optionally pass `If-Match: <etag>` for optimistic concurrency (returns 412 on conflict). Origin-gated, no API key. v3 user-scoped, path-model-canonical variant: resolves the site by (handle -> user_id) + (user_id, name).",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "the site owner's URL-safe handle"
          },
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "name": "If-Match",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {}
            }
          }
        },
        "responses": {
          "200": {
            "description": "Saved; echoes the value",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          },
          "412": {
            "description": "ETag did not match (If-Match)"
          },
          "413": {
            "$ref": "#/components/responses/TooLarge"
          }
        }
      },
      "patch": {
        "operationId": "patchSiteStateByHandle",
        "summary": "Atomically update per-site state (v3, by handle)",
        "description": "Apply one or more atomic operations so concurrent writers never clobber. Origin-gated, no API key. Ops \u2014 `set` (path,value), `inc` (path,by), `append` (path,value), `remove` (path), `removeWhere` (path,match). v3 user-scoped, path-model-canonical variant: resolves the site by (handle -> user_id) + (user_id, name).",
        "tags": [
          "State"
        ],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "the site owner's URL-safe handle"
          },
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ops"
                ],
                "properties": {
                  "ops": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "op": {
                          "type": "string",
                          "enum": [
                            "set",
                            "inc",
                            "append",
                            "remove",
                            "removeWhere"
                          ]
                        },
                        "path": {
                          "type": "string",
                          "example": "counters.visits"
                        },
                        "value": {},
                        "by": {
                          "type": "number"
                        },
                        "match": {
                          "type": "object"
                        }
                      }
                    }
                  }
                },
                "example": {
                  "ops": [
                    {
                      "op": "inc",
                      "path": "count",
                      "by": 1
                    },
                    {
                      "op": "append",
                      "path": "items",
                      "value": {
                        "id": "x"
                      }
                    }
                  ]
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "New state",
            "headers": {
              "ETag": {
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      }
    },
    "/v1/u/{handle}/sites/{sitename}/collections/{coll}": {
      "post": {
        "operationId": "postCollectionByHandle",
        "summary": "Append an item to a collection (v3, by handle)",
        "description": "Append-only list (O(1) insert). For signups, RSVPs, submissions. Origin-gated, no API key. Each item \u2264 64 KB. v3 user-scoped, path-model-canonical variant: resolves the site by (handle -> user_id) + (user_id, name).",
        "tags": [
          "Collections"
        ],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "the site owner's URL-safe handle"
          },
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/coll"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Item appended"
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      },
      "get": {
        "operationId": "getCollectionByHandle",
        "summary": "List items in a collection (v3, by handle)",
        "description": "Newest-first, paginated. Origin-gated, no API key. v3 user-scoped, path-model-canonical variant: resolves the site by (handle -> user_id) + (user_id, name).",
        "tags": [
          "Collections"
        ],
        "parameters": [
          {
            "name": "handle",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "the site owner's URL-safe handle"
          },
          {
            "$ref": "#/components/parameters/sitename"
          },
          {
            "$ref": "#/components/parameters/coll"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            }
          },
          {
            "name": "before",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "cursor for the next page"
          }
        ],
        "responses": {
          "200": {
            "description": "Items",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "type": "object"
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/OriginMismatch"
          }
        }
      }
    },
    "/v1/sites/{sitename}/view-password": {
      "put": {
        "operationId": "putSitesBySitenameViewPassword",
        "summary": "Set a view password (make the site private)",
        "description": "Password-protect the whole site (view-lock). Visitors get a custom login page + signed cookie, and the locked page's state/collections also require unlocking. Owner only (API key).",
        "tags": [
          "Private pages"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "password"
                ],
                "properties": {
                  "password": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "View password set"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "operationId": "deleteSitesBySitenameViewPassword",
        "summary": "Remove the view password (make the site public again)",
        "tags": [
          "Private pages"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "View password removed"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/allowed-origins": {
      "put": {
        "operationId": "putSitesBySitenameAllowedOrigins",
        "summary": "Allow external origins to use this site's backend",
        "description": "Whitelist extra origins (scheme://host, no path) that may call this site's state/collections API cross-origin \u2014 so a page hosted anywhere (GitHub Pages, Netlify, \u2026) can use the site as its backend for comments, feedback, counters, and forms. Replaces the whole list (max 20). Owner only. Same Origin-gated trust model as same-site calls: data stays public to the page's audience; never store secrets.",
        "tags": [
          "State"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "origins"
                ],
                "properties": {
                  "origins": {
                    "type": "array",
                    "maxItems": 20,
                    "items": {
                      "type": "string",
                      "example": "https://username.github.io"
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Allowlist replaced",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "site": {
                      "type": "string"
                    },
                    "allowed_origins": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/sites/{sitename}/domain": {
      "post": {
        "operationId": "postSitesBySitenameDomain",
        "summary": "Bind a custom domain to this site",
        "description": "Bind ONE custom domain to the site (status starts as `pending`). Returns the DNS CNAME record the owner must create (host \u2192 platform CNAME target). Once DNS points at us, on-demand TLS issues a cert and the domain is served at its root. Domains are globally unique; hijacking the platform zone is rejected. Owner only (API key).",
        "tags": [
          "Custom domains"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "domain"
                ],
                "properties": {
                  "domain": {
                    "type": "string",
                    "example": "www.example.com"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Domain bound (pending DNS)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "domain": {
                      "type": "string",
                      "example": "www.example.com"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "pending",
                        "active",
                        "error"
                      ],
                      "example": "pending"
                    },
                    "dns": {
                      "type": "object",
                      "properties": {
                        "type": {
                          "type": "string",
                          "example": "CNAME"
                        },
                        "host": {
                          "type": "string",
                          "example": "www.example.com"
                        },
                        "value": {
                          "type": "string",
                          "example": "cname.simple-host.app"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Domain already taken by another site",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      },
      "get": {
        "operationId": "getSitesBySitenameDomain",
        "summary": "Get the custom domain binding for this site",
        "description": "Returns the bound domain and status, or `{domain:null,status:null}` when none is set. Includes the CNAME DNS record to add when a domain is bound. Owner only (API key).",
        "tags": [
          "Custom domains"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "200": {
            "description": "Current domain binding",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "domain": {
                      "type": "string",
                      "nullable": true,
                      "example": "www.example.com"
                    },
                    "status": {
                      "type": "string",
                      "nullable": true,
                      "enum": [
                        "pending",
                        "active",
                        "error"
                      ]
                    },
                    "verified_at": {
                      "type": "string",
                      "format": "date-time",
                      "nullable": true
                    },
                    "last_error": {
                      "type": "string"
                    },
                    "dns": {
                      "type": "object",
                      "properties": {
                        "type": {
                          "type": "string",
                          "example": "CNAME"
                        },
                        "host": {
                          "type": "string"
                        },
                        "value": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "delete": {
        "operationId": "deleteSitesBySitenameDomain",
        "summary": "Unbind the custom domain from this site",
        "description": "Clears the domain binding and removes the serving symlink. Owner only (API key).",
        "tags": [
          "Custom domains"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/sitename"
          }
        ],
        "responses": {
          "204": {
            "description": "Domain unbound"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/templates": {
      "get": {
        "operationId": "getTemplates",
        "summary": "List starter templates",
        "description": "Public. Returns id + title + description for each template.",
        "tags": [
          "Templates"
        ],
        "responses": {
          "200": {
            "description": "Templates",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/TemplateMeta"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/templates/{id}": {
      "get": {
        "operationId": "getTemplatesById",
        "summary": "Get a template's deployable files",
        "description": "Public. Returns the template's `files` map, ready to POST to `/v1/sites/{name}/files`.",
        "tags": [
          "Templates"
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "example": "landing"
          }
        ],
        "responses": {
          "200": {
            "description": "Template with files",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/TemplateMeta"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "files": {
                          "type": "object",
                          "additionalProperties": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/v1/generate": {
      "post": {
        "operationId": "postGenerate",
        "summary": "Generate a site from a prompt (AI create)",
        "description": "Powers the home-page \"create with AI\" chat. Sign-in-gated and rate-limited (spends Anthropic credits). Send the conversation and (optionally) the current HTML and attachments; returns a short reply and, when ready, the HTML. Disabled if the server has no Anthropic key.",
        "tags": [
          "AI"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "messages": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "role": {
                          "type": "string",
                          "enum": [
                            "user",
                            "assistant"
                          ]
                        },
                        "content": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "html": {
                    "type": "string",
                    "description": "current site HTML, for incremental edits"
                  },
                  "attachments": {
                    "type": "array",
                    "description": "images / PDFs / text on the latest user turn",
                    "items": {
                      "type": "object",
                      "properties": {
                        "kind": {
                          "type": "string",
                          "enum": [
                            "image",
                            "document",
                            "text"
                          ]
                        },
                        "mediaType": {
                          "type": "string"
                        },
                        "name": {
                          "type": "string"
                        },
                        "data": {
                          "type": "string",
                          "description": "base64 (image/document)"
                        },
                        "text": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "With an agent backend, returns a jobId to poll at /v1/generate/status. With the direct backend, returns the reply (+ HTML when the model builds/updates the site) inline.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "jobId": {
                      "type": "string",
                      "description": "poll /v1/generate/status with this (async backend)"
                    },
                    "reply": {
                      "type": "string"
                    },
                    "html": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        }
      }
    },
    "/v1/generate/status": {
      "get": {
        "operationId": "getGenerateStatus",
        "summary": "Poll an async AI-create job",
        "description": "Poll the status of a job started by POST /v1/generate on the agent backend. Returns running until the agent finishes, then done with the reply and HTML. Sign-in-gated; the job is bound to the caller's key.",
        "tags": [
          "AI"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "jobId from POST /v1/generate"
          }
        ],
        "responses": {
          "200": {
            "description": "Job status",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "running",
                        "done",
                        "error"
                      ]
                    },
                    "reply": {
                      "type": "string"
                    },
                    "html": {
                      "type": "string"
                    },
                    "error": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Unknown or expired job"
          }
        }
      }
    },
    "/healthz": {
      "get": {
        "operationId": "getHealthz",
        "summary": "Liveness probe",
        "tags": [
          "Health"
        ],
        "responses": {
          "200": {
            "description": "Alive",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "ok"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/readyz": {
      "get": {
        "operationId": "getReadyz",
        "summary": "Readiness probe (DB connected)",
        "tags": [
          "Health"
        ],
        "responses": {
          "200": {
            "description": "Ready",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "example": "ok"
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Not ready"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key"
      }
    },
    "parameters": {
      "sitename": {
        "name": "sitename",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "example": "my-cool-site"
      },
      "coll": {
        "name": "coll",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "example": "signups"
      }
    },
    "schemas": {
      "AuthResponse": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "username": {
            "type": "string"
          },
          "api_key": {
            "type": "string"
          },
          "is_admin": {
            "type": "boolean"
          },
          "created": {
            "type": "boolean",
            "description": "true if this verify created the account"
          }
        }
      },
      "FilesBody": {
        "type": "object",
        "required": [
          "files"
        ],
        "properties": {
          "files": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "relative path -> file contents (text). `index.html` required."
          }
        },
        "example": {
          "files": {
            "index.html": "<!DOCTYPE html><h1>Hello</h1>",
            "css/style.css": "body{font-family:sans-serif}"
          }
        }
      },
      "Site": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "user_id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "active_version": {
            "type": "integer"
          },
          "site_url": {
            "type": "string",
            "nullable": true,
            "example": "https://my-cool-site.simple-host.app"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "SiteWithNote": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Site"
          },
          {
            "type": "object",
            "properties": {
              "note": {
                "type": "string"
              }
            }
          }
        ]
      },
      "TemplateMeta": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "landing"
          },
          "title": {
            "type": "string"
          },
          "description": {
            "type": "string"
          }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          }
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "Invalid request",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "Missing or invalid API key \u2014 send the X-API-Key header, not Authorization Bearer",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "Admin access required",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "Not found",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Conflict": {
        "description": "Site name already exists",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooLarge": {
        "description": "Payload too large",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limited",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "OriginMismatch": {
        "description": "Origin/Referer does not match the site (or the site is locked)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  }
}