AI-editable HTML and XML pages for static sites. Connect Open WebUI to this MCP / OpenAPI tool server and let the model read, write, and delete pages on your site.
Best for: hand-written HTML sites, landing pages, and static sites where the file in storage is what ships. Not for build-step frameworks (Next.js, Astro, Hugo, etc.).
What do you want?
├─ Just the MCP/API plugin
│ → plugin-only (`docker-compose.plugin.yml` or `ghcr.io/mauricewipf/capuz-cms-api`)
│
├─ Full stack, multiple containers (local demo or production VPS)
│ → multi-service stack (`docker-compose.yml` + override)
│
└─ Everything in one container
├─ Live site colocated (local demo, Render)
│ → bundled monolith, `CAPUZ_SERVE_SITE=true`, `STORAGE_BACKEND=fs`
│
└─ Laptop editor, live site on remote host
→ bundled monolith, `CAPUZ_SERVE_SITE=false`, `STORAGE_BACKEND=sftp` | `git` | `s3`
See Deploy options in this repo for compose files and commands.
Where does your site live?
├─ Same server as nginx (Docker / VPS)
│ → STORAGE_BACKEND=fs (default)
│
├─ Remote Linux server (nginx + filesystem, no Git)
│ → STORAGE_BACKEND=sftp
│
├─ Static host via Git (Pages, Netlify, Vercel, GitHub Pages)
│ → STORAGE_BACKEND=git
│
└─ S3-compatible bucket (Cloudflare R2, AWS S3, MinIO)
→ STORAGE_BACKEND=s3
Generate an API key:
export CMS_API_KEY=$(openssl rand -hex 32)Run cms-api on the same host as your web server and mount the site directory:
docker run -d \
--name cms-api \
-p 3000:3000 \
-e CMS_API_KEY="$CMS_API_KEY" \
-e STORAGE_BACKEND=fs \
-e DATA_ROOT=/app/data \
-v /var/www/site:/app/data \
ghcr.io/mauricewipf/capuz-cms-api:latestUse this when your site runs on a remote server (nginx or Apache serving files from disk) and cms-api connects over SFTP to read and write pages. The SFTP backend uses SSH key authentication only — no password auth in cms-api.
Set SFTP_REMOTE_ROOT to the absolute path of your web document root (where index.html lives), not your SSH home directory. On some hosts that looks like /var/www/site; on shared hosting it may look like /customers/…/yourdomain/httpd.www.
On the machine that runs cms-api (your laptop for local dev):
ssh-keygen -t ed25519 -f ~/.ssh/capuz_deploy_key -C "capuz-cms-api" -N ""
chmod 600 ~/.ssh/capuz_deploy_keyKeep ~/.ssh/capuz_deploy_key private. You will install ~/.ssh/capuz_deploy_key.pub on the server.
From your hosting control panel (e.g. Advanced settings → SSH & SFTP):
- Turn Allow SSH & SFTP access on
- Note hostname, username, and port
- Set the SSH/SFTP password via the panel’s reset-email flow if you have not already
Use these values for SFTP_HOST, SFTP_USER, and SFTP_PORT in .env.
The server must have your public key in ~/.ssh/authorized_keys for the SFTP user (one key per line, no extra quotes or blank lines).
Option A — VPS with SSH shell (recommended when available):
On the VPS, ensure a deploy user exists with write access to the web root, then copy your key:
# On the VPS (once)
sudo useradd -m -s /bin/bash capuz
sudo mkdir -p /var/www/site
sudo chown capuz:capuz /var/www/site# On the cms-api host
ssh-copy-id -i ~/.ssh/capuz_deploy_key.pub -p PORT USER@HOST
# Verify
ssh -i ~/.ssh/capuz_deploy_key -p PORT USER@HOST "ls /var/www/site"Option B — SFTP only (shared hosting without shell access):
Log in with password once, upload the key file, then switch to key auth.
# Local: prepare authorized_keys (exactly one line — the contents of your .pub file)
cp ~/.ssh/capuz_deploy_key.pub /tmp/authorized_keys
# Connect with password
sftp -P PORT USER@HOSTAt the sftp> prompt:
pwd # note your home directory, e.g. /home/example.com
mkdir .ssh # ignore "already exists" if present
put /tmp/authorized_keys .ssh/authorized_keys
quit
Tips for shared hosting:
- Put
authorized_keysin the SFTP user’s home directory (~/.ssh/), not in the website folder (httpd.www). - If
mkdir .sshfails, try the full path shown bypwd, e.g.mkdir /home/example.com/.ssh. - Some panels document home as
/home/yourdomain.comeven when the web root is elsewhere.
If you have shell access later, tighten permissions: chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys.
sftp -o IdentitiesOnly=yes -i ~/.ssh/capuz_deploy_key -P PORT USER@HOSTYou should connect without a password prompt. List your web root to confirm the path for SFTP_REMOTE_ROOT:
ls /var/www/site
# or, on shared hosting:
ls /customers/…/yourdomain/httpd.www
quit
Docker (plugin only):
docker run -d \
--name cms-api \
-p 3000:3000 \
-e CMS_API_KEY="$CMS_API_KEY" \
-e STORAGE_BACKEND=sftp \
-e SFTP_HOST=HOST \
-e SFTP_PORT=PORT \
-e SFTP_USER=USER \
-e SFTP_REMOTE_ROOT=/var/www/site \
-e SFTP_KEY_PATH=/keys/id_ed25519 \
-v ~/.ssh/capuz_deploy_key:/keys/id_ed25519:ro \
ghcr.io/mauricewipf/capuz-cms-api:latestBundled monolith (remote site) — set CAPUZ_SERVE_SITE=false, STORAGE_BACKEND=sftp, SFTP vars in .env (including SFTP_KEY_HOST_PATH), and add the SFTP override:
# .env
CAPUZ_SERVE_SITE=false
STORAGE_BACKEND=sftp
SFTP_KEY_HOST_PATH=/Users/YOU/.ssh/capuz_deploy_key
SFTP_KEY_PATH=/keys/id_ed25519docker compose -f docker-compose.bundled.yml -f docker-compose.bundled.sftp.yml up --buildFor Hetzner VPS deploys, see docs/DEPLOY-HETZNER.md (multi-service stack).
curl http://localhost:3000/health
curl http://localhost:3000/api/pagesYou should get {"ok":true} and a JSON list of pages from the remote site.
Add a deploy key with write access to your site repository.
docker run -d \
--name cms-api \
-p 3000:3000 \
-e CMS_API_KEY="$CMS_API_KEY" \
-e STORAGE_BACKEND=git \
-e GIT_REMOTE=git@github.com:you/yoursite.git \
-e GIT_BRANCH=main \
-e GIT_KEY_PATH=/keys/id_ed25519 \
-e GIT_AUTHOR_NAME="Capuz AI" \
-e GIT_AUTHOR_EMAIL=ai@example.com \
-v ~/.ssh/capuz_deploy_key:/keys/id_ed25519:ro \
-v cms-git-repo:/app/repo \
ghcr.io/mauricewipf/capuz-cms-api:latestEach write creates a commit and pushes. Deploy latency is typically 20–90 seconds depending on your host.
docker run -d \
--name cms-api \
-p 3000:3000 \
-e CMS_API_KEY="$CMS_API_KEY" \
-e STORAGE_BACKEND=s3 \
-e S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com \
-e S3_REGION=auto \
-e S3_BUCKET=your-bucket \
-e S3_ACCESS_KEY_ID=... \
-e S3_SECRET_ACCESS_KEY=... \
-e S3_PUBLIC_URL=https://your-site.example.com \
ghcr.io/mauricewipf/capuz-cms-api:latestFor extensionless URLs on R2, front the bucket with a small Cloudflare Worker (same role nginx plays in the fs backend).
In Admin → Settings → Integrations → Tool Servers, add an MCP connection:
[
{
"type": "mcp",
"url": "http://cms-api:3000/mcp",
"path": "/mcp",
"auth_type": "bearer",
"key": "YOUR_CMS_API_KEY",
"config": { "enable": true },
"info": {
"id": "cms-pages",
"name": "CMS Pages",
"description": "Read and write HTML files on the site"
}
},
{
"type": "mcp",
"url": "https://isitagentready.com/mcp",
"path": "",
"auth_type": "none",
"config": { "enable": true },
"info": {
"id": "agent-ready",
"name": "Agent Readiness Scanner",
"description": "Scan websites for AI agent readiness"
}
}
]The demo stack and bundled monolith include both connections via TOOL_SERVER_CONNECTIONS in docker-compose.yml / start-bundled.sh. Enable Agent Readiness Scanner in chat to call scan_site against a public URL (e.g. your SITE_HOST).
New-chat prompt chips are set via DEFAULT_PROMPT_SUGGESTIONS in docker-compose.yml (uses ${SITE_HOST} for the agent-readiness suggestion; external scans need a public hostname).
Replace the URL with wherever cms-api is reachable from Open WebUI (e.g. https://cms.example.com/mcp).
Alternatively, add the OpenAPI Tool Server using http://cms-api:3000/openapi.json with the same bearer key.
| Tool | Description |
|---|---|
get_status |
Version, deployed commit, storage backend, preview URL, and uptime |
list_pages |
List pages with {path, status} (published / draft / modified). Use changedOnly: true for unpublished changes only; detail: "paths" for legacy path strings |
list_drafts |
List paths with unpublished drafts |
read_page |
Read published page content by path |
read_draft |
Read pending draft content |
write_page |
Save HTML as a draft (returns previewUrl); does not publish |
edit_page |
Find/replace patch on a page draft without rewriting full HTML |
move_page / rename_page / copy_page |
Restructure pages (with inbound link warnings on move/rename/delete) |
search_pages |
Grep page HTML for a substring |
diff_page |
Unified diff of draft vs published |
publish_page |
Promote a draft to the live site |
discard_draft |
Delete a draft without publishing |
delete_page |
Delete a published page |
list_assets / upload_asset / read_asset / delete_asset |
Manage static assets (images, CSS, JS, fonts) |
import_chat_file |
Copy a chat-attached Open WebUI file into site assets (shared site-data volume — bundled or multi-service stack); returns url with file extension for <img src> |
list_components / read_component / write_component / delete_component |
Reusable HTML component library under .components/ (write_component creates only; existing components are immutable) |
insert_component |
Embed a tagged component block into a page draft (then edit instance data on the page) |
sync_component |
Propagate library component HTML to all tagged page drafts (overwrites instance data) |
suggest_components / reverse_engineer |
Read-only analysis: cluster recurring UI blocks from existing pages |
audit_seo / apply_seo / regenerate_sitemap |
SEO audit, metadata updates, sitemap draft generation |
check_links |
Scan for broken internal links and missing assets |
render_preview |
Screenshot a draft via external renderer (SCREENSHOT_RENDERER_URL) |
scan_site |
(Agent Readiness Scanner) Scan a public URL for AI agent readiness via isitagentready.com |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
— | Minimal health check ({ok: true}) |
| GET | /status |
— | Version, commit, storage backend, preview URL, uptime |
| GET | /openapi.json |
— | OpenAPI spec |
| GET | /api/pages |
— | List pages with published / draft / modified status (default) |
| GET | /api/pages?changedOnly=true |
— | List only draft and modified pages |
| GET | /api/pages?detail=paths |
— | Legacy: list published page paths only |
| GET | /api/pages?detail=status |
— | Same as default (explicit status) |
| GET | /api/pages/{path} |
— | Read published page |
| PUT | /api/pages/{path} |
Bearer | Save draft |
| POST | /api/pages/{path}/publish |
Bearer | Publish draft |
| POST | /api/pages/{path}/edit |
Bearer | Find/replace patch on draft |
| GET | /api/pages/{path}/diff |
— | Diff draft vs published |
| GET/POST | /api/pages/{path}/seo |
POST: Bearer | SEO audit / apply metadata to draft |
| POST | /api/pages/{path}/preview-screenshot |
Bearer | Render preview screenshot |
| POST | /api/pages/move |
Bearer | Move page |
| POST | /api/pages/rename |
Bearer | Rename page |
| POST | /api/pages/copy |
Bearer | Copy page as draft |
| DELETE | /api/pages/{path} |
Bearer | Delete published page |
| GET | /api/search?q= |
— | Search page HTML |
| GET | /api/links/check |
— | Broken link report |
| GET | /api/assets |
— | List assets |
| POST | /api/assets/import-chat |
Bearer | Import chat-attached Open WebUI file into assets (bundled/stack) |
| GET/PUT/DELETE | /api/assets/{path} |
PUT/DELETE: Bearer | Read/upload/delete asset |
| GET | /api/components |
— | List components |
| GET | /api/components/suggest |
— | Suggest components from existing pages |
| GET/PUT/DELETE | /api/components/{name} |
PUT/DELETE: Bearer | Read/create/delete component (PUT creates only; 409 if name exists) |
| POST | /api/components/{name}/insert |
Bearer | Insert component into page draft |
| POST | /api/components/{name}/sync |
Bearer | Sync component into tagged page drafts |
| POST | /api/sitemap/regenerate |
Bearer | Regenerate sitemap.xml draft |
| GET | /api/drafts |
— | List draft paths |
| GET | /api/drafts/{path} |
— | Read draft |
| PUT | /api/drafts/{path} |
Bearer | Save draft |
| POST | /api/drafts/{path}/publish |
Bearer | Publish draft |
| DELETE | /api/drafts/{path} |
Bearer | Discard draft |
| POST | /mcp |
Bearer | MCP protocol |
# Save draft
curl -H "Authorization: Bearer $CMS_API_KEY" \
-X PUT http://localhost:3000/api/pages/test.html \
-H "Content-Type: text/html" \
-d '<!DOCTYPE html><html><body>Hello</body></html>'
# Publish draft
curl -H "Authorization: Bearer $CMS_API_KEY" \
-X POST http://localhost:3000/api/pages/test.html/publishDraft pages are previewed on a separate host (subdomain). cms-api serves preview HTML when the HTTP Host matches PREVIEW_HOST. Assets load from the published site tree.
| Variable | Default (multi-service stack) | Description |
|---|---|---|
PREVIEW_HOST |
preview.localhost |
Hostname for preview vhost |
PREVIEW_BASE_URL |
http://preview.localhost:8080 |
Base URL returned in draft write responses |
DRAFTS_DIR |
.drafts |
Draft storage directory name (under DATA_ROOT or backend equivalent) |
COMPONENTS_DIR |
.components |
Reusable HTML component library directory (hidden from page listings and nginx) |
SITE_BASE_URL |
http://localhost:8080 |
Base URL for sitemap generation (use HTTPS in prod) |
SCREENSHOT_RENDERER_URL |
— | Optional POST endpoint { url } → PNG bytes for render_preview |
Multi-service stack URLs:
- Live site: http://localhost:8080
- Open WebUI: http://chat.localhost:8080
- Preview: http://preview.localhost:8080 (via edge-router)
- CMS Console: http://console.localhost:8080 (dev identity headers; production uses shared GitHub OAuth)
Bundled monolith (docker-compose.bundled.yml / Render) host routing when CAPUZ_SERVE_SITE=true:
| Host | Service |
|---|---|
SITE_HOST (default localhost) |
nginx — live published pages |
EDITOR_HOST (default chat.localhost) |
Open WebUI |
PREVIEW_HOST (default preview.localhost) |
cms-api draft preview |
When CAPUZ_SERVE_SITE=false, only EDITOR_HOST and PREVIEW_HOST are used (live site is on remote storage).
Render / production: attach custom domains for SITE_HOST, EDITOR_HOST, and PREVIEW_HOST to the service. Render does not resolve nested chat.<service>.onrender.com style hostnames — set explicit domains in the dashboard and env (see render.bundled.yaml).
Plugin-only deployments: point PREVIEW_BASE_URL at wherever preview traffic reaches cms-api (e.g. http://preview.localhost:3000 with Host: preview.localhost), or configure your own reverse proxy.
- Generate
CMS_API_KEYwithopenssl rand -hex 32. Do not use the dev default in production. - Expose port 3000 only on a private network or behind TLS (reverse proxy).
- MCP and write endpoints require bearer auth.
- Path validation blocks traversal and restricts extensions to
.htmland.xml.
Compose files and commands for each branch of the deployment mode map above.
nginx + cms-api + cms-console + Open WebUI + edge-router (Caddy):
cp .env.example .env
# set OPENROUTER_API_KEY
docker compose -f docker-compose.yml -f docker-compose.local.yml up --build -d- Public site: http://localhost:8080
- Open WebUI: http://chat.localhost:8080
- Preview: http://preview.localhost:8080
- CMS Console: http://console.localhost:8080
- CMS API: not published on the host (optional: add
-f docker-compose.local-debug.ymlfor127.0.0.1:3000)
Production on Hetzner uses the same base compose with deploy/hetzner/docker-compose.production.yml — in-compose edge-router handles TLS (Let's Encrypt via ACME_EMAIL). See docs/DEPLOY-HETZNER.md.
One Docker image (Dockerfile.bundled) for Open WebUI + cms-api + Caddy. Mode is selected with CAPUZ_SERVE_SITE:
CAPUZ_SERVE_SITE |
Live site | STORAGE_BACKEND |
|---|---|---|
true (default) |
nginx on SITE_HOST |
fs only |
false |
remote (sftp / git / s3) | sftp, git, or s3 |
Local — full site (CAPUZ_SERVE_SITE=true):
cp .env.example .env
# set OPENROUTER_API_KEY
docker compose -f docker-compose.bundled.yml up --build- Live site: http://localhost:8081
- Open WebUI: http://chat.localhost:8081
- Preview: http://preview.localhost:8081
Local — remote site (CAPUZ_SERVE_SITE=false + SFTP): see the sftp section above.
Deploy to Render with render.bundled.yaml (custom domains required):
Set OPENROUTER_API_KEY in the Render dashboard. Attach custom domains for SITE_HOST, EDITOR_HOST, and PREVIEW_HOST (e.g. example.com, chat.example.com, preview.example.com) and set matching SITE_BASE_URL, WEBUI_URL, and PREVIEW_BASE_URL.
Deploy the multi-service stack on a single VPS (docker-compose.yml + deploy/hetzner/docker-compose.production.yml):
cp deploy/hetzner/env.production.example deploy/hetzner/.env.production
# Edit secrets + SITE_HOST / EDITOR_HOST / PREVIEW_HOST / ACME_EMAIL
./deploy/hetzner/deploy-from-local.sh root@YOUR_HOST deploy/hetzner/.env.productionSee docs/DEPLOY-HETZNER.md for server sizing, DNS, verify, and operations.
| Variable | Default | Description |
|---|---|---|
STORAGE_BACKEND |
fs |
fs, sftp, git, or s3 |
CMS_API_KEY |
— | Bearer token for MCP and write endpoints |
API_PORT |
3000 |
HTTP port |
DATA_ROOT |
/app/data |
Site root (fs backend) |
DRAFTS_DIR |
.drafts |
Draft pages directory name |
COMPONENTS_DIR |
.components |
Component library directory name |
SITE_HOST |
localhost |
Public site hostname (edge-router) |
EDITOR_HOST |
chat.localhost |
Open WebUI hostname (edge-router) |
SITE_BASE_URL |
http://localhost:8080 |
Base URL for sitemap generation |
WEBUI_URL |
http://chat.localhost:8080 |
Open WebUI public URL |
SCREENSHOT_RENDERER_URL |
— | Optional screenshot renderer for preview feedback |
PREVIEW_HOST |
preview.localhost |
Preview vhost hostname |
PREVIEW_BASE_URL |
http://preview.localhost:8080 |
Preview links in API responses |
CONSOLE_HOST |
console.localhost |
CMS Console hostname (multi-service stack) |
CONSOLE_CSRF_SECRET |
— | HMAC secret for console publish/discard CSRF tokens |
CONSOLE_ALLOWED_GROUPS |
— | Optional console group allowlist (empty = any GitHub allowlisted user) |
AUTH_HOST |
— | oauth2-proxy hostname (production multi-service stack) |
GITHUB_OAUTH_CLIENT_ID / GITHUB_OAUTH_CLIENT_SECRET |
— | GitHub OAuth App credentials (production) |
GITHUB_ALLOWED_USERS |
— | GitHub logins allowed through oauth2-proxy |
OAUTH2_PROXY_COOKIE_SECRET |
— | oauth2-proxy session cookie secret (16, 24, or 32 bytes) |
OAUTH2_PROXY_COOKIE_DOMAIN |
— | Parent-domain cookie scope (e.g. .example.com) |
OAUTH2_PROXY_WHITELIST_DOMAIN |
— | Cross-subdomain redirect allowlist for oauth2-proxy |
ACME_EMAIL |
— | Let's Encrypt contact email for Hetzner stack prod (edge-router TLS) |
CAPUZ_SERVE_SITE |
true |
Bundled monolith: serve live site from nginx (fs only). Set false for remote-site mode (sftp/git/s3) |
CAPUZ_SEED |
SKIP |
nginx seed mode when CAPUZ_SERVE_SITE=true: SKIP, SEED, or FORCE_OVERWRITE |
SFTP_HOST |
— | SFTP server hostname |
SFTP_PORT |
22 |
SFTP port |
SFTP_USER |
— | SFTP username |
SFTP_KEY_HOST_PATH |
— | Host path to private key file (Compose bind mount source) |
SFTP_KEY_PATH |
— | Path to private key inside the container |
SFTP_REMOTE_ROOT |
— | Absolute path to web document root on the server |
GIT_* |
— | Git remote and deploy key settings |
S3_* |
— | S3-compatible bucket settings |
See .env.example for the full list.
- Container registry:
docker pull ghcr.io/mauricewipf/capuz-cms-api:latest - Open WebUI community listing: see docs/openwebui-listing.md
Pushing a version tag triggers .github/workflows/publish.yml, which builds multi-arch images and pushes:
ghcr.io/mauricewipf/capuz-cms-api:latestghcr.io/mauricewipf/capuz-cms-api:<version>(e.g.0.1.0)
Prerequisites: public GHCR package ghcr.io/mauricewipf/capuz-cms-api (created automatically on first tag push). Full steps are in docs/openwebui-listing.md.
git tag v0.1.0
git push origin v0.1.0
gh release create v0.1.0 --title "v0.1.0" --notes-file CHANGELOG.mdMIT — see LICENSE.
- Page operations: HTML and XML files only
- Assets: png, jpg, jpeg, gif, svg, webp, ico, css, js, woff, woff2, json
- No build-step framework support (Next.js, Astro, Hugo, etc.)
- Git backend: deploy latency depends on your host CI (typically 20–90s)
render_previewrequires an external screenshot renderer service