Developing with @craftware/crafty-sdk

Use this when writing application code that reads published Crafty content (pages/blocks, entries, collections, media) or submits forms. Authoring/configuration is done via the MCP server (see mcp-configuration.md), not the SDK.

Install

npm install @craftware/crafty-sdk

Requires Node >=20, or any runtime with fetch (browser, Bun, Deno, modern edge runtimes).

Create a client

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

const crafty = createCraftyClient({
  baseUrl: "https://<your-tenant>.crafty.do",
  apiKey: process.env.CRAFTY_API_KEY,
  siteSlug: "main-site",
  timeoutMs: 10_000,
});

createCraftyClient(config) returns a plain object with six resources (collections, entries, pages, forms, mediaCollections, mediaFiles), the image helpers image and srcset, and the visual editor helpers attrs, attrsHtml and previewFromRequest.

Config options

Option Required Notes
baseUrl yes Tenant base URL. Trailing slashes are stripped; /api/v1 is appended automatically unless already present.
apiKey no Environment API token. Sent as X-API-Key. Resolves the site + environment server-side, so pages.* need no siteSlug.
siteSlug no Site slug. Required for forms.submit() (the public submit path is /sites/{siteSlug}/forms/{formSlug}/submit).
fetch no Custom fetch. Defaults to globalThis.fetch. If none is available, construction throws CraftyError (NETWORK_ERROR).
headers no Default headers merged into every request.
timeoutMs no Default per-request timeout. A timeout is only armed when the value is a number > 0.
preview no Visual editor preview token sent as X-Crafty-Preview. Omit in the browser (read from ?crafty_preview=, kept in sessionStorage); pass previewFromRequest(req) on the server; false never sends one.
onPreviewInvalid no Called when a request that carried a token gets X-Crafty-Preview-Status: invalid (the response is the public content).

Per-call options

Every method takes an optional final options argument:

  • signal: AbortSignal — merged with the internal timeout signal.
  • headers: HeadersInit — merged over the client's default headers.
  • timeoutMs: number — overrides the client timeout for this call.
const posts = await crafty.entries.list(
  "posts",
  { per_page: 15, page: 1, sort_by: "updated_at", sort_order: "desc" },
  { timeoutMs: 5_000 }
);

Resources

collections

crafty.collections.list(options?)        // => { data: CraftyCollection[] }
crafty.collections.get(slug, options?)   // => CraftyCollection   (BARE object, not wrapped)

entries

crafty.entries.list(collectionSlug, query?, options?)  // => { data: CraftyEntry[], links, meta }  (paginated)
crafty.entries.get(collectionSlug, id, options?)       // => { data: CraftyEntry }

query: q (text search over the collection's text fields), filter (field-value conditions, e.g. { precio: { gte: 150000, lte: 300000 }, tipo: "apartamento", sector: { in: ["piantini", "naco"] } }; operators per field type in the content-modeling guide), sort_by, sort_order (asc/desc), per_page, page.

pages

crafty.pages.list(query?, options?)   // => { data: CraftyPage[], links, meta }  (paginated)
crafty.pages.get(pageSlug, options?)  // => enriched page (see below)

Pages are scoped server-side by the site the apiKey resolves to — there is no siteSlug argument. query accepts per_page and page.

pages.get() resolves to { data: CraftyPage } plus block helpers:

Helper Returns
page.blocks CraftyBlock[] (all blocks; non-array data coerced to [])
page.block(type) first block of type, or null
page.blocksOf(type) all blocks of type ([] if none)
page.hasBlock(type) boolean
page.requireBlock(type) first block, or throws a plain Error (Crafty page block not found: …) — not a CraftyError
page.byType Record<type, CraftyBlock[]>
page.firstByType Record<type, CraftyBlock | null>
page.data the raw CraftyPage
const landing = await crafty.pages.get("landing");
const hero = landing.block("hero");                 // CraftyBlock | null
const features = landing.blocksOf("feature");       // CraftyBlock[]
const cta = landing.requireBlock("cta");            // throws plain Error if absent

Block data shapes (block types)

block.data is keyed by the block type's field keys. Field values by type:

Field type block.data[key]
string, text, url string
longText sanitized HTML string
number integer
boolean boolean
stringList string[] (e.g. ["AI", "Cloud"])
repeater array of objects; every declared subfield is present, null when empty (e.g. [{ icon, title, text, tags: string[] }])
reference collection reference: entry summary { id, slug, label } or null; media reference (model: "Media"): the media id, plus <key>_media with { id, url, file_name, mime_type, size, is_public, image_transform_base } or null
multiReference collection: array of entries; media (model: "Media"): array of media ids, plus <key>_media with the files in the same order (missing files are skipped)

Entries expose media references the same way (entry.image is the id, entry.image_media.url the file).

const solutions = home.block("solutions");            // block type slug "solutions"
const items = solutions?.data.items ?? [];
items.map((item) => `${item.title}: ${item.tags.join(", ")}`);

const hero = home.block("hero");
const heroUrl = crafty.image(hero?.data.image_media, { width: 1600, format: "auto" }); // reference, model "Media"
const slides = hero?.data.gallery_media ?? [];         // multiReference, model "Media"

Images: always go through crafty.image()

Render every Crafty image with crafty.image(media, { width, format: "auto" }) (and crafty.srcset(media, widths, { format: "auto" }) for responsive images) instead of the raw url. Sites that ship the original upload fail Core Web Vitals (LCP).

const photo = block.data.image_media;

<img
  src={crafty.image(photo, { width: 800, format: "auto" })}
  srcSet={crafty.srcset(photo, [400, 800, 1200, 1600], { format: "auto" })}
  sizes="(min-width: 768px) 50vw, 100vw"
  width={800}
  height={450}
  loading="lazy"
  alt={block.data.title ?? ""}
/>
  • crafty.image(media, { width?, height?, fit?, quality?, format? }) → string. media is a <key>_media object, a CraftyMediaFile, or a URL string. width/height 1–4096, quality 1–100, fit: scale-down | contain | cover | crop | pad, format: auto | webp | avif | jpeg | png. Invalid options are dropped.
  • crafty.srcset(media, widths, options?) → "<url> 400w, <url> 800w", or "" when the image cannot be transformed.
  • Pick width from the rendered size (about 2x for retina on small images); use fit: "cover" with both width and height for fixed-ratio cards and thumbnails.
  • Transforms only apply to public raster images when the Crafty server has Cloudflare Image Transformations enabled (the media then carries image_transform_base). Otherwise image() returns the original url and srcset() returns "", so the same code always works. Upload images to a public repository (manage_media_collection with is_public: true) when the site needs permanent, resizable URLs.
  • Use og:image with crafty.image(media, { width: 1200, height: 630, fit: "cover", format: "jpeg" }): social scrapers do not support AVIF/WebP negotiation.
  • Do not hand-build /cdn-cgi/image/... URLs or append ?w= query parameters.

Prefer modelling lists as repeater / stringList fields on a block type over collections or json fields; see the crafty-cms skill.

Generated types (crafty types)

Do not guess field keys or shapes: generate them from the site's real content models with the CLI.

npx @craftware/crafty-cli types                       # CRAFTY_API_URL + CRAFTY_API_KEY from .env
npx @craftware/crafty-cli types --api-key=$KEY --url=https://<tenant>.crafty.do --out=src/crafty.types.ts

It reads GET /api/v1/schema (JSON Schema of every collection and block type of the API key's environment, versioned by format_version) and writes crafty.types.ts:

  • <Slug>Entry per collection (entry metadata + fields), <Slug>BlockData and <Slug>Block per block type, the CraftyPageBlock union (discriminated by type), the CraftyCollectionTypes / CraftyBlockDataTypes slug maps, and CraftyEntryReference / CraftyMediaSummary.
  • select → union of option values, stringList → string[], repeater → array of item objects, json → unknown; optional fields are | null.
import type { CarsEntry, CraftyPageBlock } from "./crafty.types";

const cars = (await crafty.entries.list("cars")).data as unknown as CarsEntry[];
const page = await crafty.pages.get("home");
for (const block of page.blocks as unknown as CraftyPageBlock[]) {
  if (block.type === "hero") block.data.heading; // HeroBlockData
}

Types depend on the environment (generate with the key of the environment the code targets) and go stale when a collection or block type changes — re-run the command after create_block_type, update_block_type, create_collection or update_collection.

forms

crafty.forms.list(options?)                  // => { data: CraftyForm[] }            (requires apiKey)
crafty.forms.get(slug, options?)             // => { data: CraftyForm }              (requires apiKey)
crafty.forms.submit(formSlug, data, options?) // => { id, form_id, submitted_at }   (public; apiKey optional, picks the environment)
crafty.forms.captcha(form, container)         // => CraftyFormCaptcha | null           (browser; renders the form's captcha widget)

forms.submit() uses a public endpoint: the X-API-Key, when configured, only selects the environment (a staging key submits to staging, no key submits to production), but it requires siteSlug in the client config — calling it without one throws a synchronous CraftyError with code BAD_REQUEST.

const crafty = createCraftyClient({
  baseUrl: "https://<your-tenant>.crafty.do",
  siteSlug: "main-site", // required for submit()
});

const result = await crafty.forms.submit("contact", {
  name: "Jane Doe",
  email: "[email protected]",
  message: "Hello!",
});
// => { id, form_id, submitted_at }

Forms protected by a captcha return captcha: { provider, site_key, token_field } (absent otherwise), and their submissions are rejected with 422 without the widget token. In the browser, render the widget next to the form and pass it to submit():

const { data: form } = await crafty.forms.get("contact");
const captcha = await crafty.forms.captcha(form, document.querySelector("#captcha")!); // null when not needed

await crafty.forms.submit("contact", values, { captcha }); // adds the token, then resets the widget

The provider is set by Crafty, so read it from captcha.provider instead of assuming one: Turnstile runs invisibly and only shows a challenge to suspicious visitors, hCaptcha shows a checkbox the visitor must tick, so leave room for it in the layout. Without the SDK, load the provider script yourself and post the token in captcha.token_field.

New forms get the captcha by default once Crafty has captcha keys configured; it is the Proteger con captcha box in the form's settings in the panel, or captcha: false on create_form. Turning it on for a form on a site that does not render the widget makes every submission fail with 422, so update the frontend first.

Note the asymmetry: collections.get() returns the bare object, while forms.get() and entries.get() are wrapped in { data }.

mediaCollections

crafty.mediaCollections.list(query?, options?)  // => { data: CraftyMediaCollection[], meta: { includes } }

query.include accepts "stats", "directories", or an array of them (de-duplicated and serialized comma-separated).

mediaFiles

crafty.mediaFiles.list(query?, options?)  // => { data: CraftyMediaFile[], links, meta: { ...pagination, ttl_minutes } }

query: collection, directory_id, ttl_minutes (signed-URL lifetime), per_page, page. Each file has url, is_public, expires_at and image_transform_base; pass the file to crafty.image() to render it.

llms.txt

crafty.llms fetches and serves the llms.txt (index) / llms-full.txt (full content) files Crafty generates from the site's published pages and opted-in collection entries.

crafty.llms.index(options?)                       // => string (the llms.txt body)
crafty.llms.full(options?)                         // => string (the llms-full.txt body)
crafty.llms.handler({ full?, maxAge? })            // => () => Promise<Response>  (a route handler)
crafty.llms.linkTags({ full? })                    // => string of <link> tags, ready to inline in <head>
crafty.llms.linkTagAttrs({ full? })                // => LlmsLinkTag[] (objects, for useHead / <Head>)

handler(opts) returns a zero-argument function you can assign directly to a framework's route export; it fetches full() when opts.full is true (otherwise index()) and responds with content-type: text/markdown; charset=utf-8 and cache-control: public, max-age=${opts.maxAge ?? 300}.

// Next.js — app/llms.txt/route.ts
export const GET = crafty.llms.handler();

// Next.js — app/llms-full.txt/route.ts
export const GET = crafty.llms.handler({ full: true, maxAge: 3600 });

// Astro — src/pages/llms.txt.ts
export const GET = crafty.llms.handler();

// Nuxt (h3) — server/routes/llms.txt.ts
export default defineEventHandler(() => crafty.llms.handler()());

For a static build, call index() / full() directly and write the result into dist/ at build time instead of serving a route:

import { writeFile } from "node:fs/promises";

await writeFile("dist/llms.txt", await crafty.llms.index());
await writeFile("dist/llms-full.txt", await crafty.llms.full());

linkTags() / linkTagAttrs() advertise the files in <head> (rel="alternate", type="text/markdown"); pass { full: true } to also include the llms-full.txt link.

Content and URLs: title, summary, language, and which collections without a route are opted in are configured on the "llms.txt" settings screen in the admin, or via the configure_llms MCP tool. URLs are resolved from the visual editor routes — a collection without a route pattern is excluded by default. Sites hosted by Crafty get both files injected at the site root on their next deploy (a deploy already made before you started using this feature is not retroactively updated); a file the site's own build already ships wins over the injected one.

Errors: index() / full() (and therefore handler()) throw CraftyError with code SERVER_ERROR if the response body is not text.

Analytics

crafty.analytics serves the third-party analytics (Google Tag Manager, GA4, Meta Pixel, TikTok Pixel) configured for the API key's environment on the "Analítica" admin screen or with the configure_analytics MCP tool.

crafty.analytics.get(options?)   // => { gtm_container_id, ga4_measurement_id, meta_pixel_id, tiktok_pixel_id, configured, head, body }
crafty.analytics.load(options?)  // browser only => "loaded" | "already-loaded" | "not-configured" | "unavailable"
  • Crafty Hosting: do nothing. The deploy injects the loaders into every HTML page and replaces any block the SDK rendered at build time.
  • SSR elsewhere (preferred off Crafty Hosting): render head before </head> and body right after <body>. Crawlers see the tags and the first page view is kept.
  • Client-rendered elsewhere: call load() once. It waits for the DOM, and skips without fetching when window.__CRAFTY_ANALYTICS__ is already set (by the deploy-injected block or an earlier load).

Never hard-code GTM / GA4 / pixel snippets in the site, and never add GA4 both here and inside GTM (page views are counted twice).

Structured data

The public API serves schema.org JSON-LD in structured_data: on the home page (pages.get(homeSlug), the business set with configure_structured_data), on crafty.site.get(), and on each published entry of a collection with a structured data mapping (structured_data on create_collection / update_collection). It is null when nothing is configured or a required property is empty.

crafty.site.get(options?)                 // => { name, slug, environment, default_locale, locales, structured_data }
crafty.structuredData(...sources)         // => '<script type="application/ld+json">…</script>' tags, "" when none
crafty.jsonLd(...sources)                 // => the JSON-LD objects, for useHead / <Head>
crafty.faqPage([{ question, answer }])    // => FAQPage JSON-LD for a FAQ block, or null

Sources can be pages, entries, their API responses, list responses, the site or JSON-LD objects; FAQPage objects are merged into one per page. The script content escapes <, so it is safe to inject as raw HTML (set:html, v-html, dangerouslySetInnerHTML). Validate the result at https://search.google.com/test/rich-results. On Crafty Hosting the deploy also adds the business JSON-LD to the home page (index.html) when the page does not render it already, so a site gets LocalBusiness even without the helper.

Online store

When the store is on (the site's "Tienda" tab), products are entries of the productos collection: list them with entries.list("productos") (fields nombre, precio, disponible, variantes, imagenes, sku, impuesto). Never compute totals in the frontend: the cart holds slugs, variants and quantities only, Crafty prices it, and the visitor finishes on Crafty's hosted checkout (delivery or pickup, cash, pay at pickup or bank transfer). Store calls need siteSlug in the client config; the API key, when sent, picks its environment.

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

crafty.store.get()                                   // => { enabled, currency, taxes, delivery, pickup, payment_methods, checkout_url, … }
crafty.orders.quote(cart.items(), { fulfillment })   // => { items, unavailable, subtotal, delivery_fee, taxes, total } as decimal strings
crafty.checkout.start(cart.items(), { returnUrl, cancelUrl })   // creates the session and sends the browser to the hosted checkout
crafty.checkout.create(cart.items(), options)        // => { id, url, expires_at } without redirecting
crafty.orders.get(number, token)                     // => the order, with the token from its page link (?t=)

const cart = createCart();          // localStorage, key "crafty:cart" (pass { key } per site)
cart.add("geisha", { variant: "Molido", quantity: 2 });
cart.update("geisha", 3, "Molido"); cart.remove("taza"); cart.count(); cart.subscribe(render);
cart.clearOnReturn();               // on the return page: empties the cart when the URL has ?order=, returns the number

quote().unavailable lists lines Crafty left out (not_found, sold_out, variant_not_found, variant_required, no_price). A closed store answers 404 STORE_DISABLED. After the order, "Volver a la tienda" leads to returnUrl (or the store's setting) with ?order=<number>.

Error handling

The SDK throws CraftyError for HTTP, timeout, abort, network, and config failures.

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

try {
  await crafty.pages.get("home");
} catch (error) {
  if (error instanceof CraftyError) {
    console.error(error.code, error.status, error.message, error.requestId);
  }
}

Codes: NETWORK_ERROR, TIMEOUT, BAD_REQUEST (HTTP 400 and 422), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), RATE_LIMITED (429), SERVER_ERROR (5xx), UNKNOWN_ERROR (any other / unmapped status). error.requestId is populated when the API returns an x-request-id header.

page.requireBlock() throws a plain Error, not a CraftyError — handle it separately.

End-to-end example

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

const crafty = createCraftyClient({
  baseUrl: "https://<your-tenant>.crafty.do",
  apiKey: process.env.CRAFTY_API_KEY,
  siteSlug: "main-site",
  timeoutMs: 10_000,
});

const home = await crafty.pages.get("home");
const hero = home.block("hero");

const { data: posts } = await crafty.entries.list("posts", { per_page: 15, sort_order: "desc" });
const { data: collections } = await crafty.collections.list();

const media = await crafty.mediaCollections.list({ include: ["stats", "directories"] });
const files = await crafty.mediaFiles.list({ collection: "library", per_page: 24 });

try {
  await crafty.forms.submit("contact", { name: "Jane", email: "[email protected]" });
} catch (e) {
  if (e instanceof CraftyError && e.code === "BAD_REQUEST") {
    // missing siteSlug, or validation failed
  }
}

Visual editor helpers

  • attrs(target, field, { label? }) / crafty.attrs(...) → { "data-crafty-block" | "data-crafty-entry", "data-crafty-field", "data-crafty-label"? } to spread on the element that renders the value; {} when the target has no valid id. field is a field key or a path into a list or JSON value (sections[3].title, items[2].title for a block-type repeater, tags[1] for a stringList, items[0].tags[1] for a stringList inside a repeater item). The index is the item's position in the stored data.
  • attrsHtml(target, field, { label? }) → the same attributes as an escaped HTML string.
  • image(media, options?) / crafty.image(...) and srcset(media, widths, options?) / crafty.srcset(...) → resized image URLs; see "Images: always go through crafty.image()".
  • previewFromRequest(req) → preview token of an incoming request (URL string, URL, Web Request, Node req.url, Express req.query) or null.
  • readPreviewToken() → token in the browser, or null.
  • enableVisualEditing(options) from @craftware/crafty-sdk/visual-editing → connects the page to the editor; no-op outside the admin iframe. Options: onRefresh, adminOrigins, page, entries, touchHighlightMs.

Full integration checklist: visual-editor.md.

Notes

  • Accept: application/json is set automatically unless overridden.
  • baseUrl is normalized so /api/v1 is never duplicated.
  • The apiKey (X-API-Key) is an environment token and is distinct from the Bearer tokens used for the MCP server.