Skill: berthelol/framer-crm-api (version 1.0.1)

--- name: framer-cms description: > Framer CMS management via the Server API — list, create, read, update, and delete CMS collections and items, upload images, publish previews, deploy to production, and manage project assets, all without opening Framer. Use when the user asks to manage Framer CMS content, publish a Framer site, push articles to Framer, update CMS items, upload images to Framer, create collections, sync content, or automate any Framer workflow. Trigger on: "framer", "framer cms", "framer publish", "framer deploy", "framer collection", "framer article", "push to framer", "upload to framer", "framer api", "framer server api", "cms item", "cms collection", "publish site", "deploy site", "framer preview", "framer image", "framer content". Do NOT trigger for: Framer design/layout work, Framer Motion animation library, building Framer plugins. metadata: openclaw: requires: env: - FRAMER_PROJECT_URL - FRAMER_API_KEY bins: - node - npm primaryEnv: FRAMER_API_KEY --- # Framer CMS — Server API Skill Manage Framer CMS content programmatically via the `framer-api` npm package. Push articles, upload images, create collections, and publish/deploy — all from the terminal, no Framer app needed. ## First-time setup (onboarding) If this is the first time the user uses this skill in a project, run the onboarding flow described in `references/onboarding.md`. **Quick check:** Look for `FRAMER_PROJECT_URL` and `FRAMER_API_KEY` in the user's `.env` file or environment. If missing, onboard. --- ## How it works This skill uses the **Framer Server API** (`framer-api` npm package) which connects to Framer projects via WebSocket using an API key. It provides full CMS CRUD, image uploads, publishing, and deployment. **Important:** The `framer-api` package must be installed in the project. If not present, run: ```bash npm i framer-api ``` All operations use **ES module scripts** (`.mjs` files) with this connection pattern: ```javascript import { connect } from "framer-api" // IMPORTANT: API key is passed as a plain string (2nd argument), NOT as {apiKey: "..."} const framer = await connect(process.env.FRAMER_PROJECT_URL, process.env.FRAMER_API_KEY) try { // ... operations ... } finally { await framer.disconnect() } ``` --- ## Available operations ### CMS Collections | Operation | Method | Notes | |-----------|--------|-------| | List collections | `framer.getCollections()` | Returns all CMS collections | | Get one collection | `framer.getCollection(id)` | By collection ID | | Create collection | `framer.createCollection(name)` | Creates empty collection | | Get fields | `collection.getFields()` | Field definitions (name, type, id) | | Add fields | `collection.addFields([{type, name}])` | Add new fields to collection | | Remove fields | `collection.removeFields([fieldId])` | Delete fields by ID | | Reorder fields | `collection.setFieldOrder([fieldIds])` | Set field display order | ### CMS Items (articles, entries) | Operation | Method | Notes | |-----------|--------|-------| | List items | `collection.getItems()` | All items with field data | | Create items | `collection.addItems([{slug, fieldData}])` | Create new items. Returns `undefined` — re-fetch with `getItems()` to get IDs | | Update item fields | `item.setAttributes({ fieldData: { [fieldId]: {type, value} } })` | **MUST wrap in `fieldData:`** — without it, values are silently ignored | | Update item slug/draft | `item.setAttributes({ slug: "new", draft: false })` | Slug and draft are set directly (NOT inside fieldData) | | Delete item | `item.remove()` | Single item | | Bulk delete | `collection.removeItems([itemIds])` | Multiple items | | Reorder items | `collection.setItemOrder([itemIds])` | Set display order | ### ⚠️ Critical: How to update CMS item fields The `setAttributes` method has a **non-obvious API design** — field values MUST be wrapped in a `fieldData` key: ```javascript // ✅ CORRECT — fields wrapped in fieldData await item.setAttributes({ fieldData: { [titleFieldId]: { type: "string", value: "New Title" } } }) // ❌ WRONG — silently ignored, no error thrown await item.setAttributes({ [titleFieldId]: { type: "string", value: "New Title" } }) // ❌ WRONG — also silently ignored await item.setAttributes({ [titleFieldId]: "New Title" }) ``` **Partial updates work:** Only specified fields are changed. Other fields are preserved. **Non-field attributes** (slug, draft) go directly on the object, NOT inside fieldData: ```javascript await item.setAttributes({ slug: "new-slug", draft: false }) ``` ### Field data format When creating/updating items, field data is keyed by **field ID** (not name): ```javascript const fields = await collection.getFields() const titleField = fields.find(f => f.name === "Title") await collection.addItems([{ slug: "my-article", fieldData: { [titleField.id]: { type: "string", value: "My Article Title" }, } }]) ``` **Supported field types and their value format:** | Type | Value format | Example | |------|-------------|---------| | `string` | `string` | `{ type: "string", value: "Hello" }` | | `number` | `number` | `{ type: "number", value: 42 }` | | `boolean` | `boolean` | `{ type: "boolean", value: true }` | | `date` | `string` (UTC ISO) | `{ type: "date", value: "2026-04-06T00:00:00Z" }` | | `formattedText` | `string` (HTML) | `{ type: "formattedText", value: "<h2>Title</h2><p>Text</p>" }` | | `link` | `string` (URL) | `{ type: "link", value: "https://example.com" }` | | `image` | `ImageAsset` object | See image upload section | | `enum` | `string` (case name) | `{ type: "enum", value: "Published" }` | | `color` | `string` (hex/rgba) | `{ type: "color", value: "#FF0000" }` | | `file` | `FileAsset` object | Similar to image | | `collectionReference` | `string` (item ID) | `{ type: "collectionReference", value: "itemId123" }` | | `multiCollectionReference` | `string[]` | `{ type: "multiCollectionReference", value: ["id1","id2"] }` | ### Images Upload images from public URLs, then use the returned asset in CMS items: ```javascript const asset = await framer.uploadImage("https://example.com/photo.jpg") // asset = { id, url, thumbnailUrl } await item.setAttributes({ fieldData: { [thumbnailField.id]: { type: "image", value: asset.url } } }) ``` ### Publishing & deployment ```javascript // Create a preview deployment const result = await framer.publish() // result = { deployment: { id }, hostnames: [...] } // Promote preview to production await framer.deploy(result.deployment.id) ``` **Always ask the user before deploying to production.** Publishing a preview is safe; deploying is live. ### Project info & changes ```javascript await framer.getProjectInfo() // { id, name, apiVersion1Id } await framer.getCurrentUser() // { id, name, avatar } await framer.getPublishInfo() // Current deployment status await framer.getChangedPaths() // { added, removed, modified } await framer.getChangeContributors() // Contributor UUIDs await framer.getDeployments() // All deployment history ``` ### Other operations | Operation | Method | Notes | |-----------|--------|-------| | Color styles | `getColorStyles()`, `createColorStyle()` | Design tokens | | Text styles | `getTextStyles()`, `createTextStyle()` | Typography tokens | | Code files | `getCodeFiles()`, `createCodeFile(name, code)` | Custom code overrides | | Custom code | `getCustomCode()` | Head/body code injection | | Fonts | `getFonts()` | Project fonts | | Locales | `getLocales()`, `getDefaultLocale()` | i18n | | Pages | `createWebPage(path)`, `removeNode(id)` | Page management | | Screenshots | `screenshot(nodeId, options)` | PNG buffer of any node | | Redirects | `addRedirects([{from, to}])` | Requires paid plan | | Node tree | `getNode(id)`, `getChildren(id)`, `getParent(id)` | DOM traversal | --- ## Common workflows ### Push a new article to CMS See `references/cms-operations.md` for the full pattern including field resolution, image upload, and error handling. ### Bulk update articles ```javascript const items = await collection.getItems() for (const item of items) { await item.setAttributes({ fieldData: { [metaField.id]: { type: "string", value: generateMeta(item) } } }) } ``` ### Publish after CMS changes ```javascript const changes = await framer.getChangedPaths() if (changes.added.length || changes.modified.length || changes.removed.length) { const result = await framer.publish() console.log("Preview:", result.hostnames) // Ask user before: await framer.deploy(result.deployment.id) } ``` --- ## Important notes - **API key scope:** Each key is bound to one project. For multiple Framer sites, store multiple keys. - **WebSocket connection:** The `connect()` call opens a persistent WebSocket. Always call `disconnect()` when done, or use `using framer = await connect(...)` for auto-cleanup. - **Field IDs, not names:** CMS operations use field IDs. Always call `getFields()` first and resolve names to IDs. - **Image fields:** Pass the full `framerusercontent.com` URL from `uploadImage()`, not the asset ID. - **Proxy methods:** Most methods (getCollections, publish, etc.) are proxied — they don't appear in `Object.keys(framer)` but work correctly. - **Rate limits:** No documented rate limits, but avoid hammering. Add small delays for bulk operations (100+ items). - **`formattedText` fields:** Accept standard HTML (h1-h6, p, ul, ol, li, a, strong, em, img, blockquote, pre, code, table, etc.). - **Draft items:** Items can have `draft: true` — drafts are excluded from publishing. - **Blog Posts collection:** Collections managed by `"thisPlugin"` are read-only via the API. Only `"user"` managed collections can be modified.

path: references/publishing.md
content: # Publishing & Deployment

Reference for publishing previews, deploying to production, and monitoring deployment status.

---

## Publishing workflow

Framer uses a two-step deployment model:

1. **Publish** — creates a staging/preview version
2. **Deploy** — promotes the preview to production (live site)

---

## Publish a preview

```javascript
const result = await framer.publish()
// Returns:
// {
//   deployment: { id: "deploy_abc123" },
//   hostnames: ["preview-abc123.framer.app"]
// }
```

Publishing is safe — it creates a preview URL without affecting the live site.

---

## Deploy to production

```javascript
await framer.deploy(result.deployment.id)
```

**Always confirm with the user before deploying.** This makes changes live.

---

## Check what changed before publishing

```javascript
const changes = await framer.getChangedPaths()
console.log("Added:", changes.added)
console.log("Modified:", changes.modified)
console.log("Removed:", changes.removed)
```

Use this to show the user what will be published before they confirm.

---

## Check who made changes

```javascript
const contributors = await framer.getChangeContributors()
// Returns array of contributor UUIDs
```

---

## View deployment history

```javascript
const deployments = await framer.getDeployments()
for (const d of deployments) {
  console.log(d.id, d.createdAt, d.status)
}
```

---

## Get current publish status

```javascript
const info = await framer.getPublishInfo()
// Shows current production and staging deployment info
```

---

## Full publish-and-deploy pattern

```javascript
// 1. Check for changes
const changes = await framer.getChangedPaths()
const totalChanges = changes.added.length + changes.modified.length + changes.removed.length

if (totalChanges === 0) {
  console.log("No changes to publish.")
  return
}

console.log(`${totalChanges} changes detected:`)
console.log(`  Added: ${changes.added.length}`)
console.log(`  Modified: ${changes.modified.length}`)
console.log(`  Removed: ${changes.removed.length}`)

// 2. Publish preview
const result = await framer.publish()
console.log("Preview URL:", result.hostnames[0])

// 3. Ask user to verify preview, then deploy
// await framer.deploy(result.deployment.id)
```

---

## Project info

```javascript
const info = await framer.getProjectInfo()
// { id, name, apiVersion1Id }

const user = await framer.getCurrentUser()
// { id, name, avatar }
```


path: references/project-assets.md
content: # Project Assets & Configuration

Reference for managing non-CMS project assets: styles, code files, fonts, pages, localization, and redirects.

---

## Color styles

```javascript
// List all
const styles = await framer.getColorStyles()

// Get by ID
const style = await framer.getColorStyle("styleId")

// Create
const newStyle = await framer.createColorStyle({
  name: "Brand Blue",
  light: "rgba(59, 130, 246, 1)",
  dark: "rgba(96, 165, 250, 1)",  // optional dark mode variant
})
```

---

## Text styles

```javascript
// List all
const styles = await framer.getTextStyles()

// Get by ID
const style = await framer.getTextStyle("styleId")

// Create
const newStyle = await framer.createTextStyle({
  name: "Heading 1",
  font: "Inter",
  fontSize: 48,
  fontWeight: 700,
  lineHeight: "1.2",
  letterSpacing: "-0.02em",
})
```

---

## Code files (overrides)

Custom code files that can be attached to Framer components.

```javascript
// List all
const files = await framer.getCodeFiles()

// Get by ID
const file = await framer.getCodeFile("fileId")

// Create — takes two positional arguments: (name, code)
const newFile = await framer.createCodeFile(
  "analytics.js",
  `export default function Analytics() { console.log("loaded") }`
)
```

---

## Custom code injection

Get the head/body code injection slots:

```javascript
const customCode = await framer.getCustomCode()
// Returns head and body injection content
```

---

## Fonts

```javascript
const fonts = await framer.getFonts()
for (const font of fonts) {
  console.log(font.family, font.style, font.weight)
}
```

---

## Pages

### Create a page

```javascript
const page = await framer.createWebPage("/blog/new-page")
```

### Remove a page

```javascript
await framer.removeNode(page.id)
```

### List pages

```javascript
const pages = await framer.getNodesWithType("WebPageNode")
```

---

## Node tree traversal

```javascript
// Get root
const root = await framer.getCanvasRoot()

// Navigate
const children = await framer.getChildren(nodeId)
const parent = await framer.getParent(nodeId)
const node = await framer.getNode(nodeId)

// Read text content
const text = await framer.getText(nodeId)
```

---

## Screenshots

Capture a PNG screenshot of any node:

```javascript
const buffer = await framer.screenshot(nodeId, { format: "png" })
// Returns a Buffer with the PNG image data
```

---

## Localization

```javascript
// Current project locales
const locales = await framer.getLocales()
const defaultLocale = await framer.getDefaultLocale()

// All available languages
const languages = await framer.getLocaleLanguages()

// Localization groups (pages/CMS items grouped for translation)
const groups = await framer.getLocalizationGroups()
```

---

## Redirects (paid plans only)

```javascript
// List
const redirects = await framer.getRedirects()

// Add
await framer.addRedirects([
  { from: "/old-path", to: "/new-path", expandToAllLocales: true }
])
```

Note: Redirects require a paid Framer plan. Free plans will get an error.

---

## Vector sets (icons)

```javascript
const sets = await framer.getVectorSets()
// Lists available icon sets (Iconic, Phosphor, Hero Icons, Feather, etc.)
```


path: references/cms-operations.md
content: # CMS Operations Reference

Complete reference for all CMS read/write operations via the Framer Server API.

---

## Connecting

```javascript
import { connect } from "framer-api"
import "dotenv/config" // if using dotenv

const framer = await connect(process.env.FRAMER_PROJECT_URL, process.env.FRAMER_API_KEY)
```

Always wrap operations in try/finally to ensure disconnect:

```javascript
try {
  // operations...
} finally {
  framer.disconnect()
}
```

---

## Collections

### List all collections

```javascript
const collections = await framer.getCollections()
for (const col of collections) {
  console.log(`${col.name} (${col.id}) — managed by: ${col.managedBy}`)
}
```

**Properties on a Collection:**
- `id` — unique ID
- `name` — display name
- `managedBy` — `"user"` | `"thisPlugin"` | `"anotherPlugin"`
- `slugFieldName` — name of the slug field (user collections only)
- `readonly` — whether modifications are blocked

### Get a single collection

```javascript
const col = await framer.getCollection("WB2ZX44Cx")
```

### Create a new collection

```javascript
const newCol = await framer.createCollection("My Articles")
```

This creates an empty collection. Add fields next.

### Add fields to a collection

```javascript
const fields = await newCol.addFields([
  { type: "string", name: "Title" },
  { type: "string", name: "Slug" },
  { type: "formattedText", name: "Content" },
  { type: "string", name: "Meta Description" },
  { type: "image", name: "Thumbnail" },
  { type: "date", name: "Published At" },
  { type: "boolean", name: "Featured" },
  { type: "number", name: "Read Time" },
  { type: "enum", name: "Status", cases: [{ name: "Draft" }, { name: "Published" }, { name: "Archived" }] },
])
```

**Supported field types for creation:** `string`, `number`, `boolean`, `date`, `image`, `file`, `link`, `formattedText`, `color`, `enum`, `collectionReference`, `multiCollectionReference`.

### Remove fields

```javascript
await collection.removeFields(["fieldId1", "fieldId2"])
```

### Reorder fields

```javascript
await collection.setFieldOrder(["fieldId3", "fieldId1", "fieldId2"])
```

---

## Items

### Get all items in a collection

```javascript
const items = await collection.getItems()
for (const item of items) {
  console.log(item.id, item.slug, item.draft)
  // item.fieldData is keyed by field ID
}
```

**Properties on a CollectionItem:**
- `id` — unique item ID
- `slug` — URL slug (unique within collection)
- `draft` — `true` if unpublished draft
- `fieldData` — object keyed by field ID, each value has `{ type, value }`

### Resolve field names to IDs

Always do this before creating/updating items:

```javascript
const fields = await collection.getFields()
const fieldMap = Object.fromEntries(fields.map(f => [f.name, f]))

// Now use fieldMap["Title"].id, fieldMap["Content"].id, etc.
```

### Create a new item

```javascript
const fields = await collection.getFields()
const fieldMap = Object.fromEntries(fields.map(f => [f.name, f]))

await collection.addItems([{
  slug: "my-new-article",
  fieldData: {
    [fieldMap["Title"].id]: { type: "string", value: "My New Article" },
    [fieldMap["Content"].id]: { type: "formattedText", value: "<h2>Hello</h2><p>World</p>" },
    [fieldMap["Meta Description"].id]: { type: "string", value: "A test article" },
  }
}])
```

### Update an existing item

Two approaches:

**Via setAttributes (recommended for single items):**

```javascript
const items = await collection.getItems()
const item = items.find(i => i.slug === "my-article")

const updated = await item.setAttributes({
  slug: "my-updated-article",  // optional: change slug
  fieldData: {
    [titleField.id]: { type: "string", value: "Updated Title" },
  }
})
```

`setAttributes` returns the updated item, or `null` if the item was deleted before the update.

**Via addItems with matching ID (upsert):**

```javascript
await collection.addItems([{
  id: existingItem.id,  // match by ID = update
  slug: "updated-slug",
  fieldData: { ... }
}])
```

### Delete items

**Single item:**
```javascript
await item.remove()
```

**Bulk delete:**
```javascript
await collection.removeItems(["itemId1", "itemId2", "itemId3"])
```

### Reorder items

```javascript
const items = await collection.getItems()
const reversed = items.map(i => i.id).reverse()
await collection.setItemOrder(reversed)
```

---

## Images

### Upload an image from URL

```javascript
const asset = await framer.uploadImage("https://example.com/photo.jpg")
// Returns: { id: "abc.jpg", url: "https://framerusercontent.com/images/abc.jpg", thumbnailUrl: "..." }
```

### Set image on a CMS item

```javascript
const asset = await framer.uploadImage("https://example.com/photo.jpg")

await item.setAttributes({
  fieldData: {
    [thumbnailField.id]: {
      type: "image",
      value: asset.url  // Use the full framerusercontent.com URL
    }
  }
})
```

**Important:** Pass `asset.url` (the full URL string), not the asset object or ID.

---

## Full article push example

Complete pattern for pushing an article to an existing collection:

```javascript
import { connect } from "framer-api"

const framer = await connect(projectUrl, apiKey)

try {
  // 1. Find the collection
  const collections = await framer.getCollections()
  const blog = collections.find(c => c.name === "Blog")

  // 2. Get field definitions
  const fields = await blog.getFields()
  const f = Object.fromEntries(fields.map(f => [f.name, f]))

  // 3. Upload thumbnail
  const thumb = await framer.uploadImage("https://example.com/thumb.jpg")

  // 4. Create the item
  await blog.addItems([{
    slug: "how-to-use-framer-api",
    fieldData: {
      [f["Title"].id]: { type: "string", value: "How to Use the Framer API" },
      [f["Content"].id]: { type: "formattedText", value: "<h2>Getting Started</h2><p>...</p>" },
      [f["Thumbnail"].id]: { type: "image", value: thumb.url },
      [f["Meta Description"].id]: { type: "string", value: "Learn how to..." },
    }
  }])

  // 5. Publish preview
  const result = await framer.publish()
  console.log("Preview:", result.hostnames)

  // 6. Deploy (only after user confirmation!)
  // await framer.deploy(result.deployment.id)
} finally {
  framer.disconnect()
}
```

---

## Plugin data (key-value storage)

Store arbitrary metadata on collections or items:

```javascript
// Set
await collection.setPluginData("lastSync", new Date().toISOString())

// Get
const lastSync = await collection.getPluginData("lastSync")

// List keys
const keys = await collection.getPluginDataKeys()

// Delete
await collection.setPluginData("lastSync", null)
```

Works on both `Collection` and `CollectionItem` objects.

---

## Error handling

Common errors and how to handle them:

| Error | Cause | Fix |
|-------|-------|-----|
| Connection refused | Invalid project URL or API key | Re-check credentials |
| Collection not found | Wrong ID or name | Call `getCollections()` to see available |
| Read-only collection | Collection managed by plugin (`managedBy: "thisPlugin"`) | Can only modify `"user"` collections |
| Field not found | Using field name instead of ID | Resolve via `getFields()` first |
| Duplicate slug | Slug already exists in collection | Use a unique slug or update by ID |
| Plan limitation | Feature requires paid plan (e.g., redirects) | Upgrade Framer plan |


path: references/onboarding.md
content: # Onboarding Flow

When a user first triggers this skill and doesn't have `FRAMER_PROJECT_URL` or `FRAMER_API_KEY` set up, walk them through this onboarding.

---

## Step 1: Get project URL

Ask the user:

> What's your Framer project URL?
> You can find it in the browser address bar when editing your project in Framer, e.g.:
> `https://framer.com/projects/MyProject--KCwUA0XT66ZkfkIGMSsP-0e6rK`

The URL format is: `https://framer.com/projects/{ProjectName}--{ProjectId}`

---

## Step 2: Get API key

Ask the user:

> What's your Framer Server API key?
> To create one: open your project in Framer → Site Settings → General → scroll to "Server API" → generate a key.
> Save it somewhere safe — you won't be able to see it again.

The key format is: `fr_` followed by alphanumeric characters (e.g., `fr_20xy69s6wb83bvh2mr1effssaa`).

---

## Step 3: Install framer-api

Check if `framer-api` is in the project's `node_modules`. If not:

```bash
npm i framer-api
```

If no `package.json` exists, initialize first:

```bash
npm init -y && npm i framer-api
```

---

## Step 4: Store credentials

Save the credentials to the project's `.env` file:

```bash
FRAMER_PROJECT_URL=https://framer.com/projects/MyProject--xxxxx
FRAMER_API_KEY=fr_xxxxx
```

If a `.env` file already exists, append these variables. Don't overwrite existing content.

**Important:** Check that `.env` is in `.gitignore`. If not, add it.

---

## Step 5: Test connection

Run a quick test to verify the connection works:

```javascript
import { connect } from "framer-api"

const framer = await connect(process.env.FRAMER_PROJECT_URL, process.env.FRAMER_API_KEY)
const info = await framer.getProjectInfo()
console.log("Connected to:", info.name)

const collections = await framer.getCollections()
console.log("Collections:", collections.map(c => `${c.name} (${c.id})`))

framer.disconnect()
```

If this succeeds, onboarding is complete. Show the user:
- Project name
- Number of collections found
- Collection names and IDs

---

## Step 6: Summarize

Tell the user what's available and how to use it:

> Your Framer project "{name}" is connected with {N} CMS collections:
> - {collection names}
>
> You can now:
> - List/read CMS items
> - Create/update/delete articles
> - Upload images
> - Publish previews and deploy to production
>
> Just ask me to "push an article to Framer", "list my Framer collections", or "publish my Framer site".


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)