A lightweight, white-label, plugin-extensible CMS built with TypeScript. AI-first content creation via an MCP server, with a full admin panel for manual editing.
| Layer | Technology |
|---|---|
| API | NestJS 11, MikroORM 6, PostgreSQL 16 |
| Frontend | Svelte 5 + SvelteKit 2 (SSR, adapter-node) |
| Editor | TipTap (ProseMirror) |
| Auth | JWT access tokens + opaque refresh tokens, API keys |
| Packages | pnpm workspaces |
| Infrastructure | Docker Compose |
apps/
api/ NestJS backend
web/ SvelteKit admin + frontend
packages/
shared/ Zod schemas and TypeScript types (@vesper/shared)
plugin-sdk/ Plugin interface definitions (@vesper/plugin-sdk)
theme-sdk/ CSS variable contracts + defaults (@vesper/theme-sdk)
mcp-server/ MCP server for AI content tools (@vesper/mcp-server)
plugins/
example-plugin/ Reference plugin implementation
docker/
docker-compose.yml Production
docker-compose.dev.yml Dev infrastructure (Postgres + Redis)
Dockerfile.api
Dockerfile.web
Prerequisites: Node 22+, pnpm 10+, Docker
# One-shot setup (detects and avoids port conflicts automatically)
make setup
# Seed the database (creates [email protected] / admin1234)
make seed
# Start the dev servers
make dev
Or step by step without Make:
# 1. Start dev infrastructure (Postgres + Redis)
docker compose -f docker/docker-compose.dev.yml up -d
# 2. Install dependencies
pnpm install
# 3. Build shared packages (required before starting the API)
pnpm --filter @vesper/shared build
pnpm --filter @vesper/plugin-sdk build
pnpm --filter @vesper/theme-sdk build
# 4. Copy and fill in environment variables
cp .env.example .env
# 5. Run database migrations
pnpm --filter @vesper/api migration:up
# 6. Start both apps in parallel
pnpm dev
The API will be available at http://localhost:3000/api/v1 and the admin at http://localhost:5173.
pnpm build # Build all packages and apps
pnpm test # Run all unit tests
pnpm lint # Lint everything
pnpm typecheck # Type-check all packages
pnpm --filter @vesper/api dev # API only (watch mode)
pnpm --filter @vesper/web dev # Web only (watch mode)
pnpm --filter @vesper/api migration:create # Create a new migration
pnpm --filter @vesper/api migration:up # Run pending migrations
@vesper/theme-sdk; per-site themes with global fallbackadmin, editor, viewer roles enforced by NestJS guardsA single Vesper deployment can serve any number of websites. Each site is registered with a domain name and gets its own content and theme.
blog.acme.com).set_theme call for that domain.Host header and forwards it as X-Vesper-Site to the API. The API scopes queries to the matching site automatically.Unknown domains receive empty content lists (not a fall-through to global content), ensuring strict isolation between sites.
Implement the CmsPlugin interface from @vesper/plugin-sdk:
import type { CmsPlugin } from '@vesper/plugin-sdk'
const plugin: CmsPlugin = {
name: '@my-org/my-plugin',
version: '1.0.0',
async setup(ctx) {
ctx.events.on('content.published', (e) => console.log('published', e))
},
routes: [{ method: 'GET', path: '/hello', handler: (req, res) => res.json({ ok: true }) }],
}
export default plugin
Register the plugin by adding its file path to the plugins config array.
The MCP server exposes CMS tools and resources to AI agents over stdio, enabling programmatic content and theme management.
CMS_API_URL=http://localhost:3000/api/v1 \
CMS_API_KEY=ck_your_key \
node packages/mcp-server/dist/index.js
list_sites — List all registered sites (domains)
create_site — Register a new site
name (required): Human-readable label (e.g. "Acme Corp Blog")domain (required): Hostname without protocol (e.g. "blog.acme.com")description (optional): What this site is forupdate_site — Update an existing site
id (required): Site UUIDname, domain, description, isActive (all optional)delete_site — Delete a site. Content assigned to this site becomes unassigned.
id (required): Site UUIDlist_content_types — List all available content types with their schemas and metadatalist_content — List content entries of a specific type. Supports filtering by status, site, and pagination
type (required): Content type slugstatus (optional): Filter by publication status (draft, published, archived)siteId (optional): Filter to only content assigned to this sitepage (optional, default: 1): Pagination pagelimit (optional, default: 20): Results per pagecreate_content — Create a new content entry
type (required): Content type slugdata (required): Content fields matching the schemaslug (optional): URL-friendly sluglocale (optional): Locale code (e.g., "en")siteId (optional): Assign to a specific site by UUIDupdate_content — Update an existing content entry by ID
type (required): Content type slugid (required): Content entry UUIDdata (optional): Partial fields to updateslug (optional): Updated URL slugsiteId (optional): Assign to a site (UUID) or null to unassignpublish_content — Publish a draft content entry (changes status to published)
type (required): Content type slugid (required): Content entry UUIDgenerate_seo — Auto-generate SEO metadata (title, description, Open Graph tags) for a content entrycontentId (required): Content entry UUIDget_theme — Retrieve the theme/styling configuration
domain (optional): Domain to get site-specific theme for (e.g. "blog.acme.com"). Omit for global theme.ThemeSettings object including:id: Theme settings UUIDconfig: Theme colors, typography, and design tokenscontentTypeStyles: Per-content-type custom CSSglobalCustomCss: Global custom CSS applied to all pageslocalFontCss: @font-face CSS for locally-hosted fonts (GDPR-compliant alternative to Google Fonts)updatedAt: Timestamp of last updateset_theme — Update theme configuration. Requires admin credentials. Supports partial updates for most fields
config (optional): Partial theme config object. Nested objects (e.g., light, dark, typography) are merged. Example: { light: { primary: "221 83% 53%" } }contentTypeStyles (optional): Per-content-type custom CSS as { typeSlug: cssString }. Note: This replaces the entire contentTypeStyles object; it is not merged per-typeglobalCustomCss (optional): Global custom CSS applied to all public pagesdomain (optional): Domain to update site-specific theme for (e.g. "blog.acme.com"). Omit to update global theme.create_content_type — Create a new content type schema
name (required): Human-readable nameslug (required): Unique lowercase + hyphens identifierfields (required): Array of field definitionsdescription (optional)delete_content_type — Soft-delete a content type by slug
slug (required): Content type slugThe server exposes content type schemas as MCP resources (cms://content-type/{slug}), allowing AI agents to introspect exact field structures before creating or updating content.
@vesper/website-agent)The website agent is a CLI tool and importable package that uses Claude (via the Anthropic API) to build complete, production-ready websites inside Vesper from a single natural-language description. It connects to the Vesper MCP server over stdio and drives every step — registering the site, creating content types, writing copy, applying a theme, and publishing — automatically.
pnpm --filter @vesper/mcp-server buildpnpm --filter @vesper/website-agent build| Variable | Required | Default | Description |
|---|---|---|---|
ANTHROPIC_API_KEY |
Yes | — | Anthropic API key |
CMS_API_KEY |
Yes | — | Vesper admin API key |
CMS_API_URL |
No | http://localhost:3001/api/v1 |
Vesper API base URL |
VESPER_MODEL |
No | claude-opus-4-8 |
Claude model to use |
VESPER_MCP_PATH |
No | auto-detected | Path to the compiled MCP server index.js |
VESPER_QUIET |
No | — | Set to "1" to suppress verbose output |
After building, the vesper-agent binary is available in the package's dist/:
# Build dependencies first (once)
pnpm --filter @vesper/mcp-server build
pnpm --filter @vesper/website-agent build
# Run
export ANTHROPIC_API_KEY=sk-ant-...
export CMS_API_KEY=ck_your_vesper_key
node packages/website-agent/dist/index.js "Create a portfolio website for a photographer named Alex Chen"
Or via pnpm:
pnpm --filter @vesper/website-agent dev "Create a SaaS landing page for a project management tool"
The agent follows a deterministic eight-step workflow for every run:
list_content_types and list_sites to understand the current CMS statecreate_site with the inferred domain and name if the site doesn't exist yetcreate_content_type for each type the site needs (pages, blog posts, team members, testimonials, FAQs, nav items, etc.) with appropriate field schemascreate_content for each entry, writing real copy (never Lorem Ipsum) and assigning every entry to the site via siteIdget_theme then set_theme to apply a brand-appropriate HSL color palette and any custom CSSgenerate_seo for every content entry to auto-populate meta tags and Open Graph datapublish_content for every entry so all content is liverunWebsiteAgent can be imported directly for embedding in your own tooling:
import Anthropic from '@anthropic-ai/sdk'
import { VesperMcpClient } from '@vesper/website-agent/dist/mcp-client.js'
import { runWebsiteAgent } from '@vesper/website-agent/dist/agent.js'
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
const mcpClient = new VesperMcpClient(
'http://localhost:3001/api/v1',
process.env.CMS_API_KEY,
'/path/to/mcp-server/dist/index.js',
)
await mcpClient.connect()
await runWebsiteAgent('A restaurant website for Bella Cucina in Rome', mcpClient, anthropic, {
model: 'claude-opus-4-8',
verbose: true,
})
await mcpClient.close()
runWebsiteAgent options:
| Option | Type | Default | Description |
|---|---|---|---|
model |
string |
'claude-opus-4-8' |
Claude model ID |
maxTokens |
number |
8192 |
Max tokens per Claude response |
verbose |
boolean |
true |
Print tool calls and agent text to stdout |
max_tokens continuations automatically (up to 5 times) when the model hits its token budget mid-response# Production stack
docker compose -f docker/docker-compose.yml up -d
# Build images locally
docker build -f docker/Dockerfile.api .
docker build -f docker/Dockerfile.web .
See .env.example for the full list. Required for production:
JWT_SECRET — 64+ hex chars (openssl rand -hex 64)JWT_REFRESH_SECRET — separate 64+ hex charsDB_PASSWORD