Crafty CMS (MCP + JavaScript SDK)

MCP prompt: build-site. It takes a site from a business description to a published website: content model, pages, frontend with the SDK, preview and deploy, confirming the plan with you before it writes. Arguments: description (the business and what the site needs) and optionally site_id (an existing site). Needs a token with crafty:write.

In Claude Code, type / and pick mcp__<server>__build-site, where <server> is the name you gave the Crafty server; other clients list it in their prompt menu. If your client does not show MCP prompts, ask the agent to call get_guide getting-started and follow it.

Redesigning a client's existing site? The redesign-site prompt reads their current site and social media and proposes the new one before building it (see get_guide migrate).

Crafty is a headless, multi-tenant CMS. There are two integration surfaces, and they are used at different stages:

Surface Purpose Auth Use it to
MCP server Author and configure content Bearer token (PAT or OAuth), scoped Create sites, pages, blocks, collections, entries, forms; upload media; publish
@craftware/crafty-sdk Read published content into an app X-API-Key environment token Fetch pages/blocks, entries, collections, media; submit forms from a site

The normal flow is configure with the MCP server → build a frontend with the SDK. For a new site, npm create crafty does both in one step (see Start a new site).

When to Use

  • Starting a new Crafty site or frontend project from scratch (npm create crafty, crafty create, official starters).
  • Connecting Claude, Cursor, VS Code, Codex, or ChatGPT to a Crafty tenant's MCP server.
  • Driving Crafty MCP tools (list_sites, create_page, create_entry, create_form, publish_page, upload_media, …).
  • Installing or importing @craftware/crafty-sdk / createCraftyClient.
  • Fetching Crafty pages, entries, collections, or media in app code, or submitting a Crafty form.

Start a new site

When the user wants a new site (not Crafty added to an existing frontend), don't model content and write the frontend by hand: scaffold it from an official starter.

npm create crafty@latest mi-sitio -- --url=https://<tenant>.crafty.do
# same flow: npx @craftware/crafty-cli create mi-sitio --url=https://<tenant>.crafty.do

It asks for the framework and template (today: Astro, astro-services = "sitio de servicios", astro-store = "tienda", astro-booking = "barbería / salón (citas)" or astro-docs = "documentación"; Next.js and Nuxt are coming), logs in through the browser (device flow; the user approves, an admin account is needed), creates the site with the starter's block types, collections, form and demo content, configures the visual editor routes, and writes .env (CRAFTY_API_URL, CRAFTY_API_KEY, CRAFTY_SITE), crafty.json and src/crafty.types.ts. Then:

cd mi-sitio && npm install && npm run dev   # npm run deploy → Crafty Hosting
  • Flags (after --): --url, --framework=astro, --template=astro-services|astro-store|astro-booking|astro-docs, --name, --site=<slug>, --draft (keep demo content as drafts; by default it is published so the static build shows it), --yes (no prompts). Agents should pass --url and --yes so nothing blocks on a prompt; the browser approval still needs the user.
  • The command is interactive only for the login: tell the user to open the printed URL and approve the code.
  • After it finishes, read the project's AGENTS.md and follow its conventions (one renderer per block type in src/render/blocks/, routes in src/lib/routes.ts, npm run types after changing the content model) instead of the generic steps in Phase 2.
  • If the site slug already exists it fails before creating anything; if provisioning fails midway it names the half-created site: delete it in the panel or rerun with another --site.

Scope

  • In scope: using a deployed Crafty tenant via its MCP server and the published SDK.
  • Out of scope: building or modifying the Crafty backend itself, the SDK's internals, or the publish pipeline (that's the publish-sdk skill).

Domain model

  • Site — top-level tenant resource. Everything else belongs to a site.
  • Page — a web page in a site; draft or published; composed of ordered Blocks.
  • Block — a content unit on a page; its BlockType defines the data schema. Block types can be created and edited over MCP (create_block_type, update_block_type).
  • Collection — a structured content schema (e.g. "Blog Posts"); defines the fields Entries must have.
  • Entry — one record in a collection (e.g. a single post). Belongs to a site and a collection; draft or published (new entries start as drafts; only published entries reach the SDK).
  • Form — a contact/feedback form in a site; draft or published. Published forms accept public submissions without auth.
  • Form Submission — visitor-submitted data; read-only after creation.
  • Media — files attached to a site via the Spatie Media Library.

Model content with block types first

Default to block types + pages for everything on a site: hero, stats, about, services, sectors, partners, contact, footer, and any list of items inside a section. Only reach for a collection when block types genuinely do not fit (see the exceptions below).

Why: page blocks are validated against their schema on write, show up in the visual editor with proper inputs (no raw JSON), are rendered into llms-full.txt, and travel with the page when it is published.

How to model it:

  • One block type per section (hero, stats, solutions, footer, …); the block type slug is what page.block("hero") / page.blocksOf("solutions") look up in the SDK.

  • Lists of items → a repeater field, not a json field and not a collection: items: repeater { icon, title, text, tags: stringList }. One level only (a repeater cannot contain another repeater), subfield types: string, text, longText, url, number, decimal, boolean, select, reference (incl. model: "Media" for an image per item), stringList. Optional min / max item limits.

  • url fields (top level or in a repeater) accept http(s) URLs, mailto:, tel:, #anchors and site-relative paths starting with / (mailto: allows one recipient, tel: needs at least 3 digits, IDN hosts are converted to punycode when validated); anything else (javascript:, //host, other schemes) is rejected.

  • List of plain strings → stringList (["AI", "Cloud"]).

  • Numbers: number holds integers only; prices, ratings and measures are decimal ({ key: "price", type: "decimal", min: 0, precision: 2 }). Both take optional min / max; precision is the maximum number of decimal places.

  • Fixed choices → select: { key: "condition", type: "select", options: ["new", { value: "used", label: "Used" }] } stores one option value; add multiple: true to store an array of distinct values. Options are strings or { value, label } and must be unique.

  • Images and files → media references, never url or string fields. Upload the file first (see "Media" below), then store its id:

    • one image (logo, hero, avatar, og:image): { key: "image", type: "reference", model: "Media" }, value 42;
    • several images (gallery, slider, logo wall): { key: "gallery", type: "multiReference", model: "Media" }, value [42, 43, 44];
    • an image per repeater item: a reference subfield with model: "Media" (multiReference is top-level only).

    The public API returns the id under the field key and adds <key>_media with the file ({ id, url, file_name, alt, mime_type, size, is_public, image_transform_base }, a list for multiReference), so the frontend renders crafty.image(block.data.image_media, { width: 800, format: "auto" }) with alt={block.data.image_media?.alt ?? ""}. Editors get a media picker in the panel and the visual editor can swap the image. A photo_url string field gives them none of that and breaks when the file moves.

  • Shared data (address, phone, social links, footer text used in several places): block types have no global blocks. Put it in a dedicated page (e.g. slug site-settings) with one site-settings block and read it with crafty.pages.get("site-settings").

  • Languages: do not duplicate pages per language. Set the site's locales (configure_locales, e.g. default es plus en), write the default-locale content as usual, then add each translation with upsert_translation (entries; pages with their blocks by id). The frontend passes locale (createCraftyClient({ locale }) or per call) and untranslated fields fall back to the default. Mark fields that must never change per language (SKU, codes) with translatable: false. meta.translations on get() lists the locales an item has, for hreflang. When AI translation is enabled for the workspace (from Crafty central) and for the site (configure_translation with enabled: true), translate_content translates an entry or page (with its blocks) into the site's locales without overwriting what a person wrote, and configure_translation also turns on re-translation after every change plus a glossary of untranslated terms.

  • There is no json field type in block types. If you catch yourself wanting one, model it as a repeater or stringList.

  • References to records: { type: "reference", collection: "<collection slug>" } (one entry) or multiReference (several); the API expands them into the referenced entries.

Use a collection only when the content is a large set of independent records that you list, filter, sort or paginate through the API (entries.list) — for example blog posts or a product catalogue — or when many different pages reference the same record. A handful of cards, team members or FAQs inside a page is a repeater, not a collection.

Collection fields use the same types as block types, plus json: string, text, longText, url, number (integer), decimal, boolean, datetime, select, json, reference, multiReference, stringList, repeater (same one-level rules and subfield types as in block types; a Media reference subfield gets <subkey>_media in the API). Do not fall back to json for lists in a collection — a json field is a raw JSON textarea in the panel, with no inputs, no validation of its contents and no media picker. Instead:

  • product photos, gallery → multiReference with model: "Media" (one cover image → reference with model: "Media");
  • tags, categories, related products, authors → multiReference (or reference) with collection: "<slug>" pointing at a small collection of those records; a short fixed list of labels (sizes, colors) → select with multiple: true or a stringList;
  • a short fixed set of attributes (price, sku, color) → one scalar field each (decimal for the price, select for a fixed set of values);
  • a list of rich items per entry (specs, itinerary days, FAQ of a product) → a repeater field on the collection;
  • a list of rich items that only appears inside one page → a block type repeater, not a collection.

Use json only for machine data no editor maintains (e.g. a map's coordinates array imported from elsewhere), and say so to the user when you do.

Phase 1 — Configure content via the MCP server

Connect once, then drive the tools. Minimal connect with a Personal Access Token (Claude Code shown):

claude mcp add --transport http crafty https://<your-tenant-domain>/mcp \
  --header "Authorization: Bearer YOUR_PERSONAL_ACCESS_TOKEN"

The server URL is per-tenant: https://<your-tenant-domain>/mcp. Tokens carry scopes — crafty:read, crafty:write, crafty:publish, and crafty:submissions (reading form submissions, which may contain personal data) — and they are enforced per tool (a tool without its scope is hidden and errors if called). Request the scopes the agent needs. Full connection guide for every client (PAT and OAuth), scopes, and the complete tool catalog: read references/mcp-configuration.md.

Canonical authoring workflow (exact tool names):

  1. list_sites → pick a site_id (or create_site).
  2. list_block_types → learn the available block types (it lists every site's types; use get_block_type with site_id for one site's full schema). Missing one? create_block_type (site_id, name, fields).
  3. create_page with site_id, title, and optional blocks: [{ block_type_id, data }]. Block data is validated against the block type schema; undeclared keys are dropped and reported in a warnings array in the response.
  4. publish_page.

Block types: get_block_type (site_id + block_type_id or slug) → create_block_type → update_block_type (fields replaces ALL fields; removing or retyping a field that blocks already fill needs force: true, and existing block data is never rewritten) → delete_block_type (confirm: true, refused while blocks use it).

Collections/entries: list_collections → create_entry (collection_slug, site_id, slug, data, optional status: "draft" to prepare it unpublished) → update_entry (snapshots a revision) → publish_entry / unpublish_entry. To publish or unpublish a page or entry at a given time, use schedule_publication (publish_at / unpublish_at). Forms: create_form (site_id, name, slug, fields) → publish_form → list_form_submissions. Media: upload files before you fill content, and put the returned media id in reference / multiReference fields with model: "Media" (list_media lists existing ids). upload_media (url, site_id, optional collection_name, sha256) takes an http(s) URL or a small base64 data URI (256 KB decoded). For a local binary file call request_media_upload (site_id, file_name, sha256, optional collection_name) and run the returned curl -T command from your shell; the bytes never pass through the model and the upload is verified against the sha256. Files land on a public disk only if the collection_name repository is public — create or change it with manage_media_collection (site_id, key, is_public) BEFORE uploading. Always set alt text on content images: pass alt (a concise description of what the image shows, in the site language, max 500 characters) to upload_media / request_media_upload, or set it later with update_media (site_id, media_id, alt); list_media shows alt: null for files still missing it. Purely decorative images may keep alt empty. Cache: set_api_cache_status (site_id, enabled) to toggle per-site; flush_api_cache (site_id, optional resource) to invalidate cached responses after bulk edits. Visual editor: configure_visual_editor (site_id, optional environment, frontend_url, routes, render_mode, upload_media_collection) — see references/visual-editor.md.

update_page replaces all blocks when blocks is supplied (send every block again with its id to keep it). update_entry creates a revision. create_page always starts as draft.

Migrating an existing site (WordPress, Webflow, a static site…)? Don't recreate it by hand: follow references/migrate-to-crafty.md — it uses bulk_upsert_entries (100 entries per call, drafts, dry_run), import_media_from_urls (50 URLs per call, deduplicated) and upsert_redirects so old URLs keep working.

Phase 2 — Build a frontend with the SDK

Starting a new site from scratch? Use npm create crafty (Start a new site) and follow the starter's AGENTS.md instead of the steps below. Adding Crafty to an existing frontend:

npm install @craftware/crafty-sdk

Right after a release, a stale npm cache can fail with ETARGET; retry with --prefer-online.

import { createCraftyClient } from "@craftware/crafty-sdk";

const crafty = createCraftyClient({
  baseUrl: "https://<your-tenant>.crafty.do", // SDK appends /api/v1
  apiKey: process.env.CRAFTY_API_KEY,         // environment API token (sent as X-API-Key)
  siteSlug: "main-site",                      // required ONLY for forms.submit()
});

// pages are scoped by the site the apiKey resolves to — no siteSlug argument
const page = await crafty.pages.get("home");
const hero = page.block("hero");          // first block of that type, or null
const posts = await crafty.entries.list("posts", { per_page: 15 });

Generate types before writing code against the content. Field shapes depend on the site's collections and block types — do not guess them. Run npx @craftware/crafty-cli types (reads CRAFTY_API_URL + CRAFTY_API_KEY from .env, or pass --url / --api-key; --out changes the file) to write crafty.types.ts with <Slug>Entry per collection, <Slug>BlockData / <Slug>Block per block type and the CraftyPageBlock union; cast SDK results to them (page.blocks as unknown as CraftyPageBlock[]). Types follow the API key's environment (staging may differ from production); re-run it after create_block_type / update_block_type / update_collection. The raw JSON Schema is GET /api/v1/schema. Details: references/sdk-development.md.

Images: render every Crafty image with crafty.image(media, { width, format: "auto" }) and crafty.srcset(media, [400, 800, 1200], { format: "auto" }) instead of the raw url; they return resized AVIF/WebP URLs when the server has image transformations enabled and fall back to the original url otherwise. Never hand-build /cdn-cgi/image/ URLs.

pages.get() returns the page enriched with block helpers (page.block(type), page.blocksOf(type), page.byType, …). Full client config, every resource and return shape, block helpers, errors, and an end-to-end example: read references/sdk-development.md.

Serving llms.txt / llms-full.txt for AI agents and crawlers: crafty.llms.handler() as a route (Next/Astro/Nuxt), or crafty.llms.index() / full() written into dist/ for a static build. Title, summary, language, and routeless collections are configured on the "llms.txt" admin screen or the configure_llms MCP tool. Full API, per-framework wiring, and error handling: references/sdk-development.md.

Analytics and ad pixels (Google Tag Manager, GA4, Meta Pixel, TikTok Pixel): do not hard-code their snippets into the site. Set the IDs on the "Analítica" admin screen or with the configure_analytics MCP tool; Crafty hosting injects the official loaders into every HTML page on the next deploy. If the site already loads GA4 or pixels through GTM, set only the GTM container. Hosted elsewhere: render (await crafty.analytics.get()).head before </head> in SSR, or call crafty.analytics.load() once in the browser (it skips itself when the deploy already injected them). Details: references/sdk-development.md.

Structured data (schema.org JSON-LD for Google rich results and AI search): do not write JSON-LD by hand. Fill the business data (type, address, hours, phone, social profiles) with the configure_structured_data MCP tool, map collections to Product / Article / BlogPosting / FAQPage / Event / Service with structured_data on create_collection / update_collection, and render crafty.structuredData(page, entries) in <head>: the home page carries the business JSON-LD, mapped entries their own. For FAQ blocks, crafty.structuredData(crafty.faqPage(block.data.items)). Crafty Hosting adds the business JSON-LD to the home page on deploy when the site does not render it. Details: references/sdk-development.md.

Online store (when the site's "Tienda" tab is on): products are entries of productos; keep the cart with createCart() from @craftware/crafty-sdk/cart, show totals from crafty.orders.quote(cart.items()) and send the visitor to Crafty's hosted checkout with crafty.checkout.start(cart.items()). Never compute prices or taxes in the frontend. Details: references/online-store.md.

Online bookings (when the site's "Citas" tab is on): services and professionals are entries of servicios and profesionales; show free times from crafty.bookings.availability({ service, professional }) in the browser and send the visitor to the hosted checkout with crafty.checkout.start({ booking: { service, professional, start } }). Never compute free times in the frontend. Details: references/online-bookings.md.

Phase 3 — Deploy a static site with the CLI

npm install -D @craftware/crafty-cli

Add to .env (the CLI reads this at deploy time, separate from VITE_* vars):

CRAFTY_API_URL=https://<your-tenant>.crafty.do/api/v1

Add crafty.json at the project root:

{ "site": "<site-slug>", "environment": "production", "buildDir": "dist" }

Then build and deploy:

npm run build   # produces dist/
crafty deploy   # uploads dist/ to the configured environment

crafty.json is safe to commit. .env (which contains your API key) must be gitignored — use .env.example with placeholder values.

Visual editor (click-to-edit) — always ask

Whenever you integrate Crafty into a frontend, ask the user exactly once:

¿Quieres dejar el sitio preparado para el editor visual de Crafty?

  • Yes → follow references/visual-editor.md completely: mark content with crafty.attrs() (buttons and links with hrefField only when a destination is stored; JSON paths with the stored index), deliver the preview token for the site's render mode, call enableVisualEditing() (pass client for ssr sites; React/Vue sites re-fetch and remount in onRefresh, hybrid sites cache the latest fetch), allow framing (frame-ancestors), check images are public, confirm which host really serves the domain before setting frontend_url, run configure_visual_editor, and verify (including hydration errors).
  • No → add nothing and tell the user it can be enabled later by asking «habilita el editor visual de Crafty».

A later request like «habilita el editor visual de Crafty» on an existing site runs the Yes flow without asking again.

Key distinction: MCP auth vs SDK API key

These are two different credentials — do not mix them:

  • MCP uses a Bearer token (Personal Access Token or OAuth access token) with crafty:* scopes. Used by an agent/editor to author content.
  • SDK uses an environment API token sent as the X-API-Key header. It resolves the site + environment server-side, so pages.* need no siteSlug. Used by app code to read published content.

Common pitfalls

  • Wrong tenant host → the MCP endpoint won't resolve. Use the exact https://<tenant-domain>/mcp; each tenant has its own endpoint.
  • 401 from the MCP server → the Bearer token is missing, invalid, or expired; reconnect or regenerate it.
  • A tool is missing or fails with "Insufficient OAuth scope" → the token lacks that tool's scope; re-issue it with crafty:write / crafty:publish / crafty:submissions as needed.
  • forms.submit() throws BAD_REQUEST → siteSlug was not set in createCraftyClient(). Submissions hit the public endpoint; the API key, when set, only picks the environment the submission lands in.
  • update_entry / update_page wipe fields you don't send → both MCP tools do a full replace, not a merge. If you send only the new fields, all existing fields are deleted in that revision. Always fetch the current entry first (get_entry), merge your changes into the full field map, then call update_entry with the complete merged object. Entry data is validated against the collection schema: invalid values are rejected and undeclared keys are dropped (listed in warnings).
  • create_page / update_page reject block data → block data is validated against the block type (blocks.2.data.items.1.title: …), number fields are integers (use decimal for prices), select values must be one of the declared options, string fields max 255 characters, and block_type_id must belong to the same site. Read the schema with get_block_type first.
  • Data silently missing from a block → keys not declared in the block type are dropped; check the warnings array in the response.
  • Modelled a list as a collection or a json field → use a block type with a repeater (or stringList) instead; see "Model content with block types first". Inside a collection, use a repeater, stringList, select with multiple: true or multiReference (to media or to another collection) instead of json.
  • Guessed a field name or shape (block.data.title vs heading, a select value, image vs image_media.url) → generate crafty.types.ts with crafty types and let the compiler catch it.
  • Stored an image as a url / string field (photo_url, image, logo) → upload it to the media library and use a reference (multiReference for several) with model: "Media"; render crafty.image(<key>_media, { width, format: "auto" }). Change an existing field with update_block_type / update_collection and re-save the content with media ids.
  • Serving the original image (<img src={media.url}>) → large uploads hurt LCP. Use crafty.image(media, { width, format: "auto" }) plus crafty.srcset(); it falls back to url when transforms are unavailable.
  • Uploaded images without alt text → set alt with update_media for every content image (list_media shows which have alt: null) and render <key>_media.alt in the frontend's <img alt>.
  • Content not visible via the SDK → it isn't published. Pages/forms must be published (publish_page / publish_form); entries created as drafts need publish_entry.
  • Confusing the two tokens → the SDK apiKey (X-API-Key) is not an MCP Bearer token.
  • Stale skill copy → a project copy of this skill (e.g. .claude/skills/crafty-cms) does not update with the SDK. After upgrading @craftware/crafty-sdk, copy node_modules/@craftware/crafty-sdk/skills/crafty-cms over it.

References

  • references/mcp-configuration.md — connecting every client, scopes, and the full MCP tool catalog with workflows.
  • references/sdk-development.md — the complete SDK surface, block helpers, error handling, and examples.
  • references/online-store.md — the online store: switching it on (configure_store), the products collection, cart, quotes, hosted checkout, orders and webhooks.
  • references/online-bookings.md — online bookings: switching them on (configure_bookings), services and professionals, hours and blocks, free slots, the hosted checkout, the calendar, the API, MCP tools and webhooks.
  • references/migrate-to-crafty.md — migrating an existing site (WordPress or any other) into Crafty: crawl, classify, model, bulk import as drafts, media, redirects, human review, publish.
  • references/visual-editor.md — preparing a site for the visual editor: the mandatory question, marking, preview tokens, bridge, headers, configure_visual_editor, verification.