Skip to content
KismetKismetDevelopers
llms.txt

Build editable pages

View .md

Let editors update copy, images, and page URLs to support SEO and AEO while you control the design. Use Kismet to store and review content, and render it with your own templates. Once a template is installed, editors can create more pages with that layout without a code deployment.

  1. Define the fields each kind of page needs, such as a heading, hero image, and body.
  2. Connect a template in your site to those fields.
  3. Create a draft from new or imported content.
  4. Preview and review a saved version of that draft.
  5. Publish the reviewed version to staging. Further edits stay in the draft until reviewed and published.

In the API, a content type defines the fields, a draft holds editable content, and a release is a saved version used for review or publication. Your template determines how that content looks.

Use your own editing interface and templates. The SDK provides the content operations below, rather than a visual page editor.

All routes are collection-scoped under /v1/developer/collections/{collection}/content. Use a restricted server key for configuration, authoring, and review. Keep it in your server environment; never send it to the browser or accept it from an editor form.

Capability What it permits
content.read Discover type definitions and read published staging pages; resolve an authorized preview
content.drafts.read Read channel configuration, drafts, and immutable reviews
content.configure Register versioned types and configure the staging origin and template bindings
content.write Create/edit drafts, plan/apply imports, create reviews, and restore a review into a draft
content.publish Publish an exact reviewed release to the staging channel
content.production.publish Configure production together with content.configure, promote the current staged release, or restore the preceding production release

Publishable keys can use content.read subject to their installation grants and authorized origins. They cannot use the privileged operations above. A preview also requires a valid expiring token bound to its collection, release, and origin; a hostname or query parameter alone grants no access. Prefer a server reader for preview routes so tokens are not exposed to analytics, logs, or third-party code.

Content publication is independent of booking TEST/LIVE. Both TEST and LIVE installations can have content permissions. Publication targets the configured channel; production requires the separate grant and explicit confirmation described below. Content permissions do not enable bookings or payments. The examples below run on your server with an installation key. Guest sign-in does not give a visitor permission to edit pages. If you build an editor, authorize its users on your server before calling these operations.

Import the client from /server and content definitions from /entities. In this example, story is a content type with a heading, optional image, and body. story-layout is the matching template in your site. Its pageType: 'CUSTOM' identifies a custom page for rendering and structured data; it is not the name of the content type.

import { createKismetClient } from '@kismet-tech/sdk/server';
import { defineContentType, parseContentFields } from '@kismet-tech/sdk/entities';
import type { ContentInput } from '@kismet-tech/sdk/entities';
const client = createKismetClient({
apiKey: process.env.KISMET_SERVER_KEY!,
collection: 'example-collection',
baseUrl: 'https://api.ksmt.app/v1',
});
const story = defineContentType({
id: 'story', version: 1, name: 'Story',
fields: [
{ name: 'heading', label: 'Heading', kind: 'text', required: true },
{ name: 'hero', label: 'Hero image', kind: 'image', required: false },
{ name: 'body', label: 'Body', kind: 'prose', required: true },
],
});
await client.content.types.register(story);
await client.content.channel.configure({
name: 'staging', origin: 'https://staging.example.com',
templates: [{
id: 'story-layout', version: 1,
contentType: { id: story.id, version: story.version }, pageType: 'CUSTOM',
}],
});

The origin must be a registered non-production domain for the collection and exactly match an origin authorized for the installation, with no path, query, fragment, or trailing slash. Install story-layout@1 in your site’s template registry before using that binding. Registration does not upload or deploy template code. An unsupported type/template version must show an actionable error, not silently use a different template.

A type definition is immutable at id + version; create a new version to change it. Existing channel origin and template bindings are immutable in this preview; you may add new versioned bindings. Do not mutate an old definition in place.

Portable fields support text, textarea, paragraph-only prose, image, and gallery. Images require HTTPS URLs and alt text; captions are optional. Arbitrary HTML and vendor block payloads are not accepted. Text limits, 40-image galleries, 100-paragraph prose fields, and a bounded page body are validated. The server also validates every write against the registered definition.

Capture the source, map it into your declared fields, and compute a SHA-256 hash of the captured bytes. The API accepts this mapped document; it does not fetch or crawl the source URL. Verify image rights and hosting, missing fields, links, and section order before applying the plan.

// Use the timestamp and SHA-256 hash of the source content you captured.
declare const capturedAt: string;
declare const capturedSha256: string;
const mapped: ContentInput = {
contentType: { id: story.id, version: story.version },
template: { id: 'story-layout', version: 1 },
path: '/our-story',
fields: parseContentFields(story, {
heading: 'Our story',
body: [{ type: 'paragraph', text: 'Welcome to our community.' }],
}),
seo: { title: 'Our story', description: 'Meet the people behind your stay.' },
source: { kind: 'imported', url: 'https://example.com/our-story', capturedAt, sha256: capturedSha256 },
};
const plan = await client.content.imports.plan({
key: 'our-story', expectedRevision: null, document: mapped,
});
// Review plan.action, plan.changes and plan.input before this separate write.
const draft = await client.content.imports.apply({
...plan.input, planDigest: plan.planDigest, idempotencyKey: 'import-our-story-v1',
});

expectedRevision: null means create a new document. Re-importing an existing document requires its current revision. Preserve the same idempotency key and request body when retrying an uncertain create/apply result. A different payload needs a new key and a new reviewed plan. Stale plans and revisions fail with 409; re-read and reconcile rather than overwriting an editor’s work. Draft import does not activate a public route or change production.

For original content, use client.content.drafts.create({ key, idempotencyKey, document }) with source: { kind: 'authored' }. The import operations specifically require captured-source provenance.

An update replaces the complete document; it is not a partial field patch. Publication and editing are independently authorized operations.

const edited = await client.content.drafts.update(draft.id, {
expectedRevision: draft.revision,
document: { ...mapped, fields: parseContentFields(story, {
...mapped.fields, heading: 'Meet our community',
}) },
});
const review = await client.content.reviews.create(edited.id, {
expectedRevision: edited.revision, channel: 'staging',
});
// Open review.previewUrl and review its immutable release before publishing.
const published = await client.content.reviews.publish(review.release.id);
// Restore copies a selected release into a new draft; it does not publish it.
const currentDraft = await client.content.drafts.get(edited.id);
const restored = await client.content.reviews.restore(published.id, {
expectedRevision: currentDraft.revision,
});
// Review restored.revision and explicitly publish that new review if approved.

The review saves the content, field definition, template version, page URL, and SEO together. Later draft edits do not change the review link. Publishing selects exactly what was reviewed, not the latest draft. If someone has published another version since your review was created, check their changes and create a new review. An existing page cannot be replaced by assigning its URL to a different page. Preview links expire; request a fresh review link when necessary.

Your staging route reads cmsPreview from the review URL and passes that token as the API’s preview option. Use the configured origin, not an unchecked host header.

async function loadStory(path: string, previewToken?: string) {
const page = await client.content.pages.resolve({
path,
...(previewToken ? { preview: previewToken, origin: 'https://staging.example.com' } : {}),
});
if (page.template.id !== 'story-layout' || page.template.version !== 1
|| page.contentType.id !== story.id || page.contentType.version !== story.version) {
throw new Error('Install the matching content type and template version.');
}
const fields = parseContentFields(story, page.release.document.fields);
return { releaseId: page.release.id, document: page.release.document, fields };
}
const publishedPages = await client.content.pages.list({ limit: 20 });
// Follow publishedPages.page.nextCursor for the next page; each row is a release.

pages.resolve returns { release, contentType, template }. The release contains its ID, baseline release ID, creation time, document revision, and pinned type and template. pages.list returns published staging releases, not mutable drafts. The SDK uses cache: 'no-store' for every content request.

Your site must also return Cache-Control: private, no-store and X-Robots-Tag: noindex, nofollow for staging content and its Markdown/JSON-LD representations. Keep preview tokens out of logs, telemetry, referrers and canonical URLs. Do not add drafts to public sitemaps or llms.txt. A plain shared-cacheable HTML response would defeat the API’s privacy headers.

Render HTML, JSON-LD and Markdown from the same release ID within a request; do not combine a reviewed body with live draft SEO or a newer type definition. If a shared Kismet.Page wrapper receives a runtime-selected KismetPageDefinition, pass your validated graph through its jsonLd prop as { replaceGeneratedGraph: graph }. This supports a dynamic page type without a cast and emits one graph; do not also insert a separate JSON-LD script. CUSTOM pages still require an explicit graph. Render prose as escaped text, not raw HTML. Configure image hosting for your chosen renderer. A second page of the same type should use the same dynamic route and template registry, without a new per-page component or code deployment.

Terminal window
curl --request GET \
"https://api.ksmt.app/v1/developer/collections/example-collection/content/pages/resolve?path=%2Four-story" \
--header "Authorization: Bearer $KISMET_SERVER_KEY" \
--header "Accept: application/json"
Failure Recovery
400 invalid fields, version, origin or route Correct the named input; do not retry unchanged
401 / 403 credential or grant failure Check the installation, collection, capability and permitted origin; a guest session is not an editor session
403 invalid, expired or wrong-origin preview Obtain a fresh authorized review link; do not fall back to the draft
404 missing document, release or published route Discover/read within the same collection and use the returned identifiers
409 stale revision, plan, baseline or route conflict Re-read and reconcile, then create a new plan/review; never silently retry against latest
429 / 503 temporary service failure Respect backoff; preserve idempotency keys for uncertain create/apply results

See the Content (preview) API reference for request fields, response schemas, and permissions. Each operation also has a Markdown version for coding agents.

The staging and production channels have independent publication pointers. Editing a draft or publishing to staging leaves production unchanged. Register production with content.channel.configure using name: 'production', the collection’s registered production origin, and the exact renderer bindings used on staging. This requires both content.configure and content.production.publish; a manager using OAuth must be an administrator. Staging and production must use different origins. No existing credential gains production authority automatically.

The renderer deployment must already support the registered template. Promotion switches content only; it does not deploy code, configure DNS, or prove that the deployed renderer matches its declared version. Complete that integration check before connecting a real production domain.

const productionStatus = await client.content.production.status(draft.id);
if (!productionStatus.staging || !productionStatus.origin) throw new Error('Connect production and publish to staging first.');
// Show the destination, staged revision, and current production revision to the publisher.
// After explicit confirmation, promote this exact release. Preserve the request key for retries.
const productionRequest = {
expectedProductionVersion: productionStatus.version,
expectedProductionOrigin: productionStatus.origin,
idempotencyKey: 'promote-our-story-reviewed-v1',
};
await client.content.production.promote(productionStatus.staging.id, productionRequest);
const livePage = await client.content.pages.resolve({path: '/our-story', channel: 'production'});
const liveIndex = await client.content.pages.list({channel: 'production'});

The server rejects stale staging releases, changed production versions, destination mismatches, incompatible template bindings and conflicting routes. The promotion uses the reviewed snapshot even if the draft has since changed. The returned release keeps its original staging review identity; selecting channel: 'production' chooses which publication pointer delivers it. Omitted channel selection continues to mean staging for installed clients.

To restore the preceding production release, load fresh status, show the previous and current revisions and destination, then confirm the rollback:

const rollbackStatus = await client.content.production.status(draft.id);
if (!rollbackStatus.previous || !rollbackStatus.origin) throw new Error('No previous production release.');
await client.content.production.rollback(draft.id, {
expectedProductionVersion: rollbackStatus.version,
expectedProductionOrigin: rollbackStatus.origin,
idempotencyKey: 'restore-our-story-previous-v1',
});

Rollback also requires content.production.publish. It updates only production; it leaves staging and draft edits intact. Production versions increase on each change, including restores, so old confirmations cannot accidentally be reused. Use production selection consistently for HTML, Markdown, JSON-LD, sitemap and LLM discovery so all surfaces share the same release ID.