Skip to content

AI Agents Guide

Start path A

This is the deep guide for the Agents (MCP) path. For the map of all paths, see Quick start and the AI Agents overview.

This page covers everything an AI agent (Claude, GPT, Cursor, or any LLM-based system) needs to read and manage luno content — from no-auth public content reading to full content creation via the Agent API and MCP.

Overview

luno supports three integration models for AI agents:

MethodAuthCapability
Public APINoneRead published content
MCP ServerAgent API keyContent and schema ops via Claude Code / Cursor / Codex
Agent APIAgent API keySame Admin API routes, programmatic access

MCP package: @luno-cms/mcp (npx -y @luno-cms/mcp)

Client support: Claude Code, Cursor, and Codex are all Verified (Golden Path E2E: apply builtin template → create/save/publish entry → funnel events).

MCP Server Setup

luno ships a Model Context Protocol (MCP) server that lets Claude Code, Cursor, Codex, and other MCP-compatible tools interact with your CMS in natural language.

Package: @luno-cms/mcp — the npm README is the canonical setup guide.

From the root of your site repository:

bash
cd my-existing-site
npx @luno-cms/mcp setup
# → pick 1) Claude Code  2) Cursor  3) Codex
ChoiceWhat gets written
Claude Code.claude/skills/luno/ + .mcp.json
Cursor.cursor/skills/luno/ + .cursor/mcp.json
Codex.agents/skills/luno/ + .codex/config.toml

Keys live under .agents/luno/ (gitignored; only *.example is committed):

FileRole
.agents/luno/dev.envLocal API (http://127.0.0.1:8787/admin)
.agents/luno/stg.envStaging (https://stg-api.luno.rest/admin)
.agents/luno/prod.envProduction (https://api.luno.rest/admin)
.agents/luno/envActive env (updated by env switch)

Each *.env holds:

bash
LUNO_API_URL=https://api.luno.rest/admin
LUNO_AGENT_KEY=sk-agent-xxxxxxxx

Then:

  1. Issue a key in the admin panel (Settings → Agent API Keys)
  2. Paste it via /luno in the agent, or non-interactively:
bash
npx @luno-cms/mcp env set-key stg 'sk-agent-…'
npx @luno-cms/mcp env switch stg
npx @luno-cms/mcp env status

MCP server names: luno-dev / luno-stg / luno-prod
(npx @luno-cms/mcp run stg loads .agents/luno/stg.env)

TIP

Do not commit .agents/luno/*.env. Keep secrets out of git; use one key per environment / site as needed.

After setup — client-specific notes

ClientWhat to do after env set-key / env switch
Claude CodeRestart / reconnect MCP if tools are missing (/mcp)
CursorSettings → MCP: enable luno-stg (green). Open a new Agent chat if tools do not appear in an existing chat. Leave luno-dev / luno-prod Disabled until those keys exist
CodexSetup writes project .codex/config.toml (with cwd) and prints codex mcp add luno-<env> --env LUNO_PROJECT_ROOT="<siteRoot>" -- npx -y @luno-cms/mcp run <env> because Codex prefers ~/.codex. Interactive setup offers home registration; --yes prints commands only. Verify: codex mcp list (expect luno-stg, etc.). First MCP tool calls may require approval. Prefer luno-stg when that env is active

Environment variables (what the MCP process reads)

VariableExampleDescription
LUNO_API_URLhttps://api.luno.rest/adminAdmin API base (include /admin, no trailing slash)
LUNO_AGENT_KEYsk-agent-…Agent API key from the admin panel

Prefer storing these in .agents/luno/{dev,stg,prod}.env rather than pasting keys into shared MCP JSON. For local API use http://127.0.0.1:8787/admin.

Alternative: inline env in MCP config (Claude Desktop / one-off)

If you are not using setup / .agents/luno/, you can put the variables directly in the MCP client config.

Claude Desktop — edit ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):

json
{
  "mcpServers": {
    "luno": {
      "command": "npx",
      "args": ["-y", "@luno-cms/mcp"],
      "env": {
        "LUNO_API_URL": "https://api.luno.rest/admin",
        "LUNO_AGENT_KEY": "sk-agent-xxxxxxxx"
      }
    }
  }
}

Cursor — Settings → MCP, or project .cursor/mcp.json with the same shape. The admin panel also shows a copy-paste snippet after you issue a key (Settings → Agent API Keys).

For day-to-day site work, prefer npx @luno-cms/mcp setup so keys stay in .agents/luno/ and you can switch dev / stg / prod.

Troubleshooting for Agents

SymptomLikely causeWhat to doRetry same input?
Missing / invalid slug or name (tool args)Required args omittedPass required fields from the tool descriptionNo
Slug already exists for this tenant (+ hint)Form Set slug collisionlist_form_sets or pick a new slugNo
Slug already exists for this form setEntry slug collisionlist_entries or new slugNo
REVISION_CONFLICT / revision mismatchStale revision / revisionRowIdlist_revisions; use save_revision’s id + revision for publish_revisionNo
401 / Invalid agent keyBad or missing keynpx @luno-cms/mcp env set-key … then reconnect MCPNo
429 / RATE_LIMITEDPer-key rate limit exceededWait for Retry-After seconds; reduce tight tool loopsYes (after wait)
Timeout after createNetwork / client abortRetry with the same idempotencyKeyYes (keyed creates)

API errors may include additive error.hint and error.retryable (OpenAPI ApiError). When retryable is false, change input before calling again.

Idempotency keys (optional)

Admin UI does not send keys — behavior without a key is unchanged. Agents may pass idempotencyKey on major creates (or Idempotency-Key header):

MCP toolWithout keySame key replay
apply_form_blueprint / apply_builtin_form_templateCreate / 409 on slug clashSame 201 body
create_entryNew row / 409Same entry id
save_revisionAlways new revisionSame revision row
create_contact_formNew / 409Same id
publish_revisionExisting already_published + outbox dedupe(no extra key needed)

Issuing an Agent API Key

AI agents that call the Admin API (including the MCP server) need an agent API key.

  1. Open Settings → Agent API Keys (/settings/api-keys) in the admin panel
  2. Set a descriptive name (e.g., Claude Agent, Setup Bot)
  3. Choose a scope (see below)
  4. Save and copy the key (sk-agent-…) — it is shown only once

Protect your API key

  • Never expose it in client-side code or Git repositories
  • Store it as a server-side environment variable or in a secret manager
  • If compromised, revoke it from the Agent API Keys page and generate a new one

Key Scopes

Each agent key has a scope that limits what it can do. Keys are bound to the project where they were issued (X-Project-Id is not required).

ScopeUse forCapabilities
full (recommended)Articles + schema setupEntries, media, Form Set / Contact / Blueprint
contentArticles onlyRead schema, create/update entries, save/publish revisions, list media
schemaCompatibility aliasSame capabilities as full
  1. Day-to-day: issue a full key (or content if you want articles only)
  2. Optional: use a short-lived key for initial blueprint / template apply, then revoke it if you prefer tighter long-lived scopes

What agent keys cannot do (any scope)

  • Delete Form Sets or Contact Forms
  • Delete form blocks or field definitions
  • Issue other API keys, invite members, or change billing / SNS settings

Calling a schema-only endpoint with a content key returns 403 Forbidden.

Rate limits

Agent API keys (sk-agent-…) are rate-limited on Admin API requests. JWT console sessions are not limited by this feature.

PlanActive keysRequests / window (per key)
Free / Solo160 / 60 seconds
Standard / Business / EnterpriseMultiple300 / 60 seconds

When exceeded:

  • HTTP 429
  • Error code RATE_LIMITED
  • Response header Retry-After (seconds until the window resets, best-effort)

Limits are enforced in-memory per Cloudflare Workers isolate (best-effort — not strictly shared across isolates). Normal MCP / Golden Path usage should stay well under the Free tier cap.

Handling 429 in agents

Respect Retry-After before retrying. Batch reads (get_project_overview, list_entries with pagination) instead of tight tool loops.

MCP Tools

Tools exposed by @luno-cms/mcp (canonical list: npm README):

Resuming an existing project

  1. get_project_overview — inventory summary (recommended first call)
  2. Then get_form_set_schema / list_entries as needed
  3. Separate from the Golden Path for new sites (builtin template → entry → publish)

Content (content / full)

ToolDescription
get_project_overviewProject summary (form sets, contact forms, masters, storage, login appearance, IP allowlist, locales, public API)
get_tenant_schemaProject-wide schema (all form sets)
list_form_sets / get_form_set_schemaForm set list and field definitions (masterEntityKey / sampleValues for selects)
get_public_api_infoprojectId and Public API base (/public/p/{projectId}/v1)
list_entries / get_entryEntry list and detail
create_entry / update_entryCreate entries and update slugs
list_revisions / save_revision / publish_revisionRevision workflow
submit_entry_for_reviewSubmit for approval
list_media / upload_mediaMedia list and upload (filePath / sourceUrl / base64)
list_master_entities / get_master_entityMaster entities
list_master_records / create_master_recordMaster records (list / create)
update_master_record / update_master_treeMaster updates (not available with agent keys — needs user JWT)
get_project_content_localesContent locale settings
patch_project_content_localesUpdate locales (tenant_admin JWT only)
translate_entry_localesAI bulk locale translation (Standard+)
search_admin_help / get_admin_help_article / ask_admin_helpAdmin help KB
get_login_branding / get_login_appearance / update_login_appearanceLogin branding
list_console_login_ip_allowlists / add_… / delete_…Login IP allowlist (Business+)

Schema setup (full / schema required)

ToolAdmin API
apply_form_blueprintPOST /admin/v1/form-blueprints/apply
validate_master_blueprint / apply_master_blueprintValidate / apply master blueprints
apply_builtin_form_templatePOST /admin/v1/form-set-templates/:id/apply
create_contact_form / update_contact_formContact forms (supports autoreply_*)

dryRun (schema apply preview)

apply_form_blueprint, apply_master_blueprint, and apply_builtin_form_template accept dryRun: true and return a preview without writing to the database.

CLI equivalents: hcms form apply --dry-run, hcms template apply --dry-run.

llms.txt

luno provides a llms.txt-compliant endpoint per site that lists published content (form sets and entry URLs):

bash
curl https://api.luno.rest/public/v1/llms.txt
# Or on your project's public host:
curl https://your-domain.com/public/v1/llms.txt

Embed the response in a system prompt so the agent knows what content exists:

[System prompt]
You are a content assistant for a developer blog. Available CMS content:

{contents of llms.txt}

Use the Public API to read entries and the Agent API (via MCP) to draft new articles.

For full API specifications, use this documentation site (Public API, AI Agents Guide) rather than a separate llms-full.txt endpoint.

Reading Content (No Auth Required)

Public API base: https://{your-domain}/public/v1

Discover available content

bash
# Step 1: Published content index
curl https://your-domain.com/public/v1/llms.txt

# Step 2: Sitemap
curl https://your-domain.com/public/v1/sitemap.xml

Fetch an entry list

bash
curl "https://api.luno.rest/public/p/{projectId}/v1/form-sets/blog/entries?include_snapshot=true&limit=10"
ts
const BASE = 'https://api.luno.rest/public/p/{projectId}/v1'
const res = await fetch(
  `${BASE}/form-sets/blog/entries?include_snapshot=true&limit=10`
)
const data = await res.json()
bash
npx @luno-cms/mcp setup
# Agent prompt example: "List 10 published blog entries with bodies"

Python — paginate all entries

python
import httpx
import asyncio

BASE_URL = "https://your-domain.com/public/v1"

async def fetch_all_entries(form_set_slug: str) -> list[dict]:
    """Fetch all published entries from a form set, handling pagination."""
    all_items = []
    page = 1
    limit = 100

    async with httpx.AsyncClient() as client:
        while True:
            res = await client.get(
                f"{BASE_URL}/form-sets/{form_set_slug}/entries",
                params={
                    "page": page,
                    "limit": limit,
                    "include_snapshot": "true",
                }
            )
            res.raise_for_status()
            data = res.json()

            all_items.extend(data["items"])

            total = data["total"]
            offset = data["offset"] + limit
            if offset >= total:
                break
            page += 1

    return all_items

async def main():
    entries = await fetch_all_entries("blog")
    for item in entries:
        slug = item["entry"]["slug"]
        title = item["published"].get("snapshot", {}).get("title", "(no title)")
        print(f"{slug}: {title}")

asyncio.run(main())

TypeScript — typed client

typescript
const BASE_URL = 'https://your-domain.com/public/v1'

interface PublishedItem {
  entry: { id: string; slug: string }
  published: {
    revisionId: string
    revision: number
    updatedAt: string
    snapshot?: Record<string, unknown>
    mediaUrls?: Record<string, string>
  }
}

interface EntryListResponse {
  formSet: { id: string; slug: string; name: string }
  total: number
  limit: number
  offset: number
  items: PublishedItem[]
}

async function fetchEntries(
  formSetSlug: string,
  options: { page?: number; includeSnapshot?: boolean } = {}
): Promise<EntryListResponse> {
  const { page = 1, includeSnapshot = false } = options
  const url = new URL(`${BASE_URL}/form-sets/${formSetSlug}/entries`)
  url.searchParams.set('page', String(page))
  url.searchParams.set('limit', '20')
  if (includeSnapshot) url.searchParams.set('include_snapshot', 'true')

  const res = await fetch(url.toString())
  if (!res.ok) throw new Error(`API error ${res.status}: ${res.statusText}`)
  return res.json()
}

Writing Content via the Agent API

Agent API base: https://{your-domain}/admin/v1

Authenticate with Authorization: Bearer sk-agent-….

List form sets

bash
curl https://api.luno.rest/admin/v1/form-sets \
  -H "Authorization: Bearer sk-agent-xxxxxxxx"

Create an entry

bash
curl -X POST "https://api.luno.rest/admin/v1/form-sets/{formSetId}/entries" \
  -H "Authorization: Bearer sk-agent-xxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "ai-generated-post-2025",
    "fields": {
      "title": "Building with Cloudflare Workers in 2025",
      "body": "<p>Introduction...</p>",
      "category": "tutorial",
      "tags": ["cloudflare", "workers"]
    }
  }'

Save and publish a revision

Use MCP tools save_revision and publish_revision, or call the Admin API directly:

bash
curl -X POST "https://api.luno.rest/admin/v1/revisions/{revisionId}/publish" \
  -H "Authorization: Bearer sk-agent-xxxxxxxx"

Example Claude Conversations (with MCP)

Reading content:

User: What are the 5 most recent blog posts?

Claude: [calls luno MCP → fetches entries] Here are the 5 most recent posts...

Creating content:

User: Draft a blog post about "What's new in Cloudflare Workers 2025"

Claude: [calls luno MCP → creates draft entry] I've created a draft with slug cloudflare-workers-2025. Would you like me to add more detail to any section?

Publishing:

User: The "getting-started" post looks good, go ahead and publish it.

Claude: [calls luno MCP → publishes revision] Published. It's now live at /blog/getting-started.

Initial setup (schema key):

User: Apply the blog template and create a contact form.

Claude: [calls apply_builtin_form_template and create_contact_form] Form set and contact form are ready.

Field Value Types

Field typeValue typeExample
text / urlstring"My Post Title" / "https://…"
textareastring"A brief excerpt."
tiptapTiptap doc (JSON) or string"<p>Body content</p>"
numbernumber42
booleanbooleantrue
datestring or { from, to }"2025-01-15"
select / radiostring (master value)"blog"
multiselectstring[]["cloudflare", "cms"]
image / filestring (asset UUID)"550e8400-..."
image_galleryUUID string or { assetId, caption? }[][{ "assetId": "…" }]
video_embedstring (URL)"https://youtube.com/..."
entry_refstring (referenced entry UUID)"7c9e6679-..."

Resolve image / file UUIDs using mediaUrls[fieldKey] from the response.

Error Handling

HTTP StatusCodeHow to handle
400VALIDATION_ERRORFix the request parameters per the error message
401UNAUTHORIZEDAPI key is invalid, revoked, or missing
429RATE_LIMITEDWait for Retry-After seconds, then retry; reduce parallel tool calls
403FORBIDDENScope too narrow (e.g., blueprint apply with content key)
403PLAN_REQUIREDFull-text search (?q=) requires Business plan+
404NOT_FOUNDVerify the slug or ID
301Slug changed — follow the Location header
304Content unchanged — use cached version

Best Practices

  1. Start with llms.txt to see what published content exists
  2. Prefer full day-to-day — use content to restrict to entries; revoke short-lived setup keys when done
  3. Use include_snapshot=true on list requests to avoid per-entry round trips
  4. Follow 301 redirects — slugs can change after entries are renamed
  5. Cache with ETags — send If-None-Match to avoid re-downloading unchanged content
  6. Review before publishing — use the draft → pending_review → published workflow even for AI-generated content

Next Steps