Migrating an existing site to Crafty

MCP prompt: migrate-site. It runs this whole runbook for you and stops at every checkpoint for you to confirm. Arguments: site_id (the target site), source (the old site's URL, its sitemap.xml or a description of the export) and optionally environment (default production) and research (default yes: it also researches the brand on the web and proposes content the old site lacks; no for a strict 1:1 move). Needs a token with crafty:write.

In Claude Code, type / and pick mcp__<server>__migrate-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 migrate and follow it.

Redesign instead of a 1:1 move? MCP prompt: redesign-site. It studies the client's current site, social media and what the rest of the web says about them, sums up their brand, voice and content, and proposes a new site (site map, sections built from your block types, draft copy, visual direction) before it creates anything. Once you approve the proposal it builds it as drafts and never publishes. Arguments: website (the current site's URL) and optionally social (profile URLs or handles), goals and site_id (leave it out to make the proposal before the client has a Crafty site). Needs a token with crafty:write. Social networks often block automated reading, so be ready to paste the bio and a few posts or share screenshots.

Runbook for moving a site that already exists (WordPress, Webflow, Wix, a static site, another headless CMS) into Crafty with an agent and the MCP server, without breaking its URLs. Work in this order; each phase has a checkpoint where a human confirms before the next one.

Tools used: bulk_upsert_entries, import_media_from_urls, upsert_redirects (plus the usual create_block_type, create_collection, create_page, publish_*). Token scopes: crafty:read + crafty:write; add crafty:publish only for the final phase.

Golden rules

  • Everything lands as a draft. Never pass publish: true until a human has reviewed the content in the panel.
  • dry_run: true first on every bulk_upsert_entries and upsert_redirects batch; fix the failed items, then run it for real.
  • Use external_id (the old CMS post/page ID, or the old URL when there is no ID) on every entry. Re-running an import then updates instead of duplicating.
  • Batches: up to 100 entries, 50 media URLs, 500 redirects per call. Keep a local manifest (JSON/CSV) of what each batch sent and what came back (entry_id, media_id), so a failed run can resume.
  • Media quota: imports count against the tenant's media quota. Import only what the pages actually use, skip thumbnails and resized copies (WordPress -300x200.jpg variants), and ask before importing more than a few hundred files.
  • Work against the production environment of the target site unless the user asks for another (tools take environment).

Phase 1 — Crawl

Build an inventory of every URL of the old site:

  1. Sources, best first: the CMS export or API (WordPress REST /wp-json/wp/v2/posts?per_page=100&page=N, /pages, /media; or the WXR export), sitemap.xml (and sitemap indexes), then a crawl of internal links from the home page.
  2. For each URL record: old path, title, type guess, language, last modified, the main content (HTML), the images and files it uses, and the old ID if known.
  3. Also collect the URLs that already redirect (HTTP 301/302) and anything in the old site's redirect plugin or .htaccess: those must be carried over too.
  4. Research the brand beyond its site: search the web for its name (Google Business profile and reviews, social media, press, directories and marketplaces, award or partner pages). List what the old site lacks as proposed additions: reviews and testimonials, FAQs drawn from customer questions, opening hours, locations, services or products listed elsewhere, awards, press mentions, social links. Give each one its source URL and how sure you are, flag information that contradicts the site or looks outdated, and never invent facts. Keep the additions apart from the inventory: they are proposals, not migrated content. Skip this step for a strict 1:1 move (the migrate-site prompt takes research: "no").

Checkpoint: show the user the counts per type, any URL you could not fetch and the proposed additions. Only the additions the user picks are added, in phase 5, as new drafts.

Phase 2 — Classify

Group the URLs into:

  • Singleton pages (home, about, contact, landing pages) → Crafty pages built from block types.
  • Repeating content (posts, news, products, team members, case studies, events) → one collection per type, one entry per item.
  • Archives and taxonomy listings (/category/x, /tag/y, /page/2, author pages, date archives) → usually not migrated as content: the new frontend generates them; they only need redirects.
  • Junk (search results, feeds, attachment pages, ?replytocom= …) → drop; redirect to the closest useful page when they had traffic.

Decide the new URL scheme with the user (e.g. /blog/2019/05/hello-world → /blog/hello-world), and write the old → new mapping into the inventory now.

Checkpoint: the user approves the content types and the URL scheme.

Phase 3 — Model

Follow "Model content with block types first" in SKILL.md:

  1. Inspect the current schemas: list_block_types, list_collections.
  2. For singleton pages, create_block_type per recurring section (hero, text + image, cards → repeater, FAQ → repeater, CTA). Reuse existing block types when they fit.
  3. For each repeating type, create_collection with the fields the content really has: title (string), excerpt (text), body (longText, HTML), cover (reference, model: "Media"), gallery (multiReference), published_on (datetime), tags (stringList), category (select), SEO fields (seo_title, seo_description). No json fields for content editors maintain.
  4. Mark the repository the images will go to as public first if the site needs permanent URLs: manage_media_collection with is_public: true.

Checkpoint: the user reviews the schemas in the panel before any import.

Phase 4 — Media

  1. From the inventory, list the distinct image and file URLs the migrated content uses (full-size originals only).
  2. import_media_from_urls in batches of up to 50: { site_id, urls, collection_name }. Each result has status (imported, existing when the URL was already imported, duplicate within the batch, failed) and media_id / original_url.
  3. Save the old URL → media_id / original_url map. Retry failed items once; report the rest (private hosts, redirects, too large or unsupported types are refused on purpose — download those locally and use request_media_upload).

Phase 5 — Content (as drafts)

  1. Transform each item to the collection schema: clean the HTML (drop builder shortcodes, inline styles, tracking pixels), rewrite internal links to the new paths, and replace image URLs in the HTML with the original_url values from phase 4. Put media references (cover, gallery) as media_id values.
  2. bulk_upsert_entries with { site_id, collection_slug, entries: [{ slug, external_id, data }], dry_run: true }. Fix every failed item (the errors name the field, e.g. data.published_on: …) and check warnings (dropped keys = a field missing from the schema).
  3. Run the same batch without dry_run. Entries are created as drafts; existing ones (same external_id or slug) are updated with a revision.
  4. Write the additions the user picked in phase 1 as new drafts (no external_id from the old site) and list them, with their sources, in the review report.
  5. Singleton pages: create_page with blocks in the default locale; they start as drafts too. Other languages of the old site go in with configure_locales and upsert_translation, not as extra pages.

Phase 6 — Redirects

  1. From the inventory, one redirect per old URL whose path changes: { source: "/old/path", destination: "/new/path", status_code: 301 }. Use 301 for permanent moves; 302/307 only for temporary ones.
  2. Collapse patterns into dynamic rules when possible (/category/* → /blog, /blog/:year/:month/:slug → /blog/:slug): Crafty hosting allows 2000 static and 100 dynamic rules per deploy, including the site's own _redirects.
  3. source must be a path (no domain, no ?query, no #fragment); destination a path or an absolute http(s) URL. Don't redirect a URL to itself or create chains (A → B → C: point A straight at C).
  4. upsert_redirects with dry_run: true, fix failed, then for real (500 per call). Check with list_redirects.
  5. Hosted on Crafty: they apply on the next deploy. Custom frontend: read GET /api/v1/redirects in the middleware (Next.js middleware.ts, Nuxt server middleware) and answer with the status_code and Location header.

Phase 7 — Human review

Give the user a short report: counts created/updated/failed per type, media imported/skipped, redirects created, and the list of items that need a human (failed transformations, missing images, pages that did not map to a block type). Ask them to review the drafts in the panel (and in the visual editor if it is configured). Apply their corrections with update_entry / update_page, or re-run the import batch (it updates by external_id).

Phase 8 — Publish and switch over

Only after the user's explicit go-ahead:

  1. Publish: bulk_upsert_entries again with publish: true (token with crafty:publish) or publish_entry / publish_page per item; or schedule_publication for a set launch time.
  2. Deploy the new frontend, point the domain at it, then spot-check a sample of old URLs (they must answer 301 to the new ones) and the sitemap.
  3. Keep the redirects: removing them later breaks links and search rankings.

Not covered yet

There is no WordPress WXR importer, sitemap importer or broken-link report in the panel: the agent does those steps itself with the tools above.