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 optionallysite_id(an existing site). Needs a token withcrafty:write.In Claude Code, type
/and pickmcp__<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 callget_guide getting-startedand follow it.Redesigning a client's existing site? The
redesign-siteprompt reads their current site and social media and proposes the new one before building it (seeget_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--urland--yesso 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.mdand follow its conventions (one renderer per block type insrc/render/blocks/, routes insrc/lib/routes.ts,npm run typesafter 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-sdkskill).
Domain model
- Site — top-level tenant resource. Everything else belongs to a site.
- Page — a web page in a site;
draftorpublished; 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;
draftorpublished(new entries start as drafts; only published entries reach the SDK). - Form — a contact/feedback form in a site;
draftorpublished. 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 typeslugis whatpage.block("hero")/page.blocksOf("solutions")look up in the SDK.Lists of items → a
repeaterfield, not ajsonfield 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. Optionalmin/maxitem limits.urlfields (top level or in a repeater) accept http(s) URLs,mailto:,tel:,#anchorsand 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:
numberholds integers only; prices, ratings and measures aredecimal({ key: "price", type: "decimal", min: 0, precision: 2 }). Both take optionalmin/max;precisionis the maximum number of decimal places.Fixed choices →
select:{ key: "condition", type: "select", options: ["new", { value: "used", label: "Used" }] }stores one option value; addmultiple: trueto store an array of distinct values. Options are strings or{ value, label }and must be unique.Images and files → media references, never
urlorstringfields. Upload the file first (see "Media" below), then store its id:- one image (logo, hero, avatar,
og:image):{ key: "image", type: "reference", model: "Media" }, value42; - several images (gallery, slider, logo wall):
{ key: "gallery", type: "multiReference", model: "Media" }, value[42, 43, 44]; - an image per repeater item: a
referencesubfield withmodel: "Media"(multiReferenceis top-level only).
The public API returns the id under the field key and adds
<key>_mediawith the file ({ id, url, file_name, alt, mime_type, size, is_public, image_transform_base }, a list formultiReference), so the frontend renderscrafty.image(block.data.image_media, { width: 800, format: "auto" })withalt={block.data.image_media?.alt ?? ""}. Editors get a media picker in the panel and the visual editor can swap the image. Aphoto_urlstring field gives them none of that and breaks when the file moves.- one image (logo, hero, avatar,
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 onesite-settingsblock and read it withcrafty.pages.get("site-settings").Languages: do not duplicate pages per language. Set the site's locales (
configure_locales, e.g. defaultesplusen), write the default-locale content as usual, then add each translation withupsert_translation(entries; pages with their blocks by id). The frontend passeslocale(createCraftyClient({ locale })or per call) and untranslated fields fall back to the default. Mark fields that must never change per language (SKU, codes) withtranslatable: false.meta.translationsonget()lists the locales an item has, forhreflang. When AI translation is enabled for the workspace (from Crafty central) and for the site (configure_translationwithenabled: true),translate_contenttranslates an entry or page (with its blocks) into the site's locales without overwriting what a person wrote, andconfigure_translationalso turns on re-translation after every change plus a glossary of untranslated terms.There is no
jsonfield type in block types. If you catch yourself wanting one, model it as a repeater orstringList.References to records:
{ type: "reference", collection: "<collection slug>" }(one entry) ormultiReference(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 →
multiReferencewithmodel: "Media"(one cover image →referencewithmodel: "Media"); - tags, categories, related products, authors →
multiReference(orreference) withcollection: "<slug>"pointing at a small collection of those records; a short fixed list of labels (sizes, colors) →selectwithmultiple: trueor astringList; - a short fixed set of attributes (price, sku, color) → one scalar field each (
decimalfor the price,selectfor a fixed set of values); - a list of rich items per entry (specs, itinerary days, FAQ of a product) → a
repeaterfield 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):
list_sites→ pick asite_id(orcreate_site).list_block_types→ learn the available block types (it lists every site's types; useget_block_typewithsite_idfor one site's full schema). Missing one?create_block_type(site_id,name,fields).create_pagewithsite_id,title, and optionalblocks: [{ block_type_id, data }]. Blockdatais validated against the block type schema; undeclared keys are dropped and reported in awarningsarray in the response.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_pagereplaces all blocks whenblocksis supplied (send every block again with itsidto keep it).update_entrycreates a revision.create_pagealways starts asdraft.
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.jsonis safe to commit..env(which contains your API key) must be gitignored — use.env.examplewith 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.mdcompletely: mark content withcrafty.attrs()(buttons and links withhrefFieldonly when a destination is stored; JSON paths with the stored index), deliver the preview token for the site's render mode, callenableVisualEditing()(passclientforssrsites; React/Vue sites re-fetch and remount inonRefresh, hybrid sites cache the latest fetch), allow framing (frame-ancestors), check images are public, confirm which host really serves the domain before settingfrontend_url, runconfigure_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-Keyheader. It resolves the site + environment server-side, sopages.*need nositeSlug. 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:submissionsas needed. forms.submit()throwsBAD_REQUEST→siteSlugwas not set increateCraftyClient(). Submissions hit the public endpoint; the API key, when set, only picks the environment the submission lands in.update_entry/update_pagewipe 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 callupdate_entrywith the complete merged object. Entrydatais validated against the collection schema: invalid values are rejected and undeclared keys are dropped (listed inwarnings).create_page/update_pagereject block data → block data is validated against the block type (blocks.2.data.items.1.title: …),numberfields are integers (usedecimalfor prices),selectvalues must be one of the declared options,stringfields max 255 characters, andblock_type_idmust belong to the same site. Read the schema withget_block_typefirst.- Data silently missing from a block → keys not declared in the block type are dropped; check the
warningsarray in the response. - Modelled a list as a collection or a
jsonfield → use a block type with arepeater(orstringList) instead; see "Model content with block types first". Inside a collection, use arepeater,stringList,selectwithmultiple: trueormultiReference(to media or to another collection) instead ofjson. - Guessed a field name or shape (
block.data.titlevsheading, a select value,imagevsimage_media.url) → generatecrafty.types.tswithcrafty typesand let the compiler catch it. - Stored an image as a
url/stringfield (photo_url,image,logo) → upload it to the media library and use areference(multiReferencefor several) withmodel: "Media"; rendercrafty.image(<key>_media, { width, format: "auto" }). Change an existing field withupdate_block_type/update_collectionand re-save the content with media ids. - Serving the original image (
<img src={media.url}>) → large uploads hurt LCP. Usecrafty.image(media, { width, format: "auto" })pluscrafty.srcset(); it falls back tourlwhen transforms are unavailable. - Uploaded images without alt text → set
altwithupdate_mediafor every content image (list_mediashows which havealt: null) and render<key>_media.altin 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 needpublish_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, copynode_modules/@craftware/crafty-sdk/skills/crafty-cmsover 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.