Skip to content

Latest commit

 

History

85 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Capuz CMS API — Open WebUI Plugin

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.).

Choose a deployment mode

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.

Choose a storage backend

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

Quick start (Docker)

Generate an API key:

export CMS_API_KEY=$(openssl rand -hex 32)

fs — colocated with nginx (default)

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:latest

sftp — remote nginx or shared hosting

Use 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.

1. Generate a deploy key

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_key

Keep ~/.ssh/capuz_deploy_key private. You will install ~/.ssh/capuz_deploy_key.pub on the server.

2. Get connection details and enable SFTP

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.

3. Install your public key on the server

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@HOST

At 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_keys in the SFTP user’s home directory (~/.ssh/), not in the website folder (httpd.www).
  • If mkdir .ssh fails, try the full path shown by pwd, e.g. mkdir /home/example.com/.ssh.
  • Some panels document home as /home/yourdomain.com even when the web root is elsewhere.

If you have shell access later, tighten permissions: chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys.

4. Test key-based SFTP

sftp -o IdentitiesOnly=yes -i ~/.ssh/capuz_deploy_key -P PORT USER@HOST

You 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

5. Run cms-api

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:latest

Bundled 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_ed25519
docker compose -f docker-compose.bundled.yml -f docker-compose.bundled.sftp.yml up --build

For Hetzner VPS deploys, see docs/DEPLOY-HETZNER.md (multi-service stack).

6. Verify cms-api

curl http://localhost:3000/health
curl http://localhost:3000/api/pages

You should get {"ok":true} and a JSON list of pages from the remote site.

git — static host auto-deploy

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:latest

Each write creates a commit and pushes. Deploy latency is typically 20–90 seconds depending on your host.

s3 — Cloudflare R2 / AWS S3 (instant deploy)

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:latest

For extensionless URLs on R2, front the bucket with a small Cloudflare Worker (same role nginx plays in the fs backend).

Connect to Open WebUI

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.

MCP tools

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

REST API

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/publish

Preview

Draft 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:

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.

Security

  • Generate CMS_API_KEY with openssl 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 .html and .xml.

Deploy options in this repo

Compose files and commands for each branch of the deployment mode map above.

Multi-service stack

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

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.

Bundled monolith (single container)

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

Local — remote site (CAPUZ_SERVE_SITE=false + SFTP): see the sftp section above.

Deploy to Render with render.bundled.yaml (custom domains required):

Deploy to Render

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.

Hetzner VPS

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.production

See docs/DEPLOY-HETZNER.md for server sizing, DNS, verify, and operations.

Environment variables

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.

Publish / install

  • Container registry: docker pull ghcr.io/mauricewipf/capuz-cms-api:latest
  • Open WebUI community listing: see docs/openwebui-listing.md

First release

Pushing a version tag triggers .github/workflows/publish.yml, which builds multi-arch images and pushes:

  • ghcr.io/mauricewipf/capuz-cms-api:latest
  • ghcr.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.md

License

MIT — see LICENSE.

Limitations

  • 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_preview requires an external screenshot renderer service

About

MCP Server for editing static HTML files via Agent Harness

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages