Back to skills
SkillHub ClubWrite Technical DocsFull StackBackendTech Writer

payload

This skill should be used when the user asks to "create a post", "edit a post", "update post content", "list posts", "convert markdown to Lexical", "write article to Payload", or mentions Payload CMS content management. Handles both production and local Payload sites via REST API with authentication.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
10
Hot score
84
Updated
March 20, 2026
Overall rating
C2.2
Composite score
2.2
Best-practice grade
N/A

Install command

npx @skill-hub/cli install b-open-io-prompts-payload
cmsapicontent-managementweb-development

Repository

b-open-io/prompts

Skill path: skills/payload

This skill should be used when the user asks to "create a post", "edit a post", "update post content", "list posts", "convert markdown to Lexical", "write article to Payload", or mentions Payload CMS content management. Handles both production and local Payload sites via REST API with authentication.

Open repository

Best for

Primary workflow: Write Technical Docs.

Technical facets: Full Stack, Backend, Tech Writer.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: b-open-io.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install payload into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/b-open-io/prompts before adding payload to shared team environments
  • Use payload for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: payload
description: This skill should be used when the user asks to "create a post", "edit a post", "update post content", "list posts", "convert markdown to Lexical", "write article to Payload", or mentions Payload CMS content management. Handles both production and local Payload sites via REST API with authentication.
version: 0.4.0
---

# Payload CMS Operations

Manage Payload CMS content via REST API. Works with any Payload deployment (production or local).

## When to Use

- Creating or editing posts/pages in Payload CMS
- Converting markdown content to Lexical rich text format
- Listing and querying Payload collections
- Bulk content updates

## Workflow: REST API with Authentication

### Step 1: Determine the API Endpoint

Ask the user for their Payload site URL, or check common locations:

```bash
# Production site (ask user or check project config)
curl -s "https://your-site.com/api/posts?limit=1" | head -c 100

# Local development
curl -s "http://localhost:3000/api/posts?limit=1" 2>/dev/null | head -c 100
curl -s "http://localhost:3010/api/posts?limit=1" 2>/dev/null | head -c 100
```

### Step 2: Authenticate

For mutations (create/update/delete), authentication is required. Payload uses session-based auth.

**Option A: User provides credentials**
```bash
# Login to get auth token
curl -X POST "https://your-site.com/api/users/login" \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]", "password": "..."}' \
  -c cookies.txt

# Use the cookie file for authenticated requests
curl -X POST "https://your-site.com/api/posts" \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"title": "...", "content": {...}}'
```

**Option B: User logs in via admin UI**
Have the user log in at `/admin`, then extract the `payload-token` cookie from their browser for use in API calls.

### Step 3: Create/Update Content

```bash
# Create a post
curl -X POST "https://your-site.com/api/posts" \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{
    "title": "Post Title",
    "slug": "post-slug",
    "content": { "root": { ... } },
    "_status": "published"
  }'

# Update a post
curl -X PATCH "https://your-site.com/api/posts/POST_ID" \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"content": { "root": { ... } }}'
```

### Step 4: Verify

```bash
# Check the post was created
curl -s "https://your-site.com/api/posts?where[slug][equals]=post-slug" | jq '.docs[0]'
```

## Lexical JSON Structure

Payload's Lexical editor stores content as JSON:

```json
{
  "root": {
    "type": "root",
    "format": "",
    "indent": 0,
    "version": 1,
    "direction": "ltr",
    "children": [
      {
        "type": "paragraph",
        "format": "",
        "indent": 0,
        "version": 1,
        "direction": "ltr",
        "children": [
          {"type": "text", "text": "Content here", "mode": "normal", "format": 0, "detail": 0, "version": 1, "style": ""}
        ]
      }
    ]
  }
}
```

### Supported Node Types

| Markdown | Lexical Node |
|----------|--------------|
| Paragraphs | `paragraph` |
| `# Heading` | `heading` with tag h1-h6 |
| `**bold**` | text with format: 1 |
| `*italic*` | text with format: 2 |
| `` `code` `` | text with format: 16 |
| Code blocks | `block` with blockType: "code" |
| Lists | `list` with `listitem` children |
| `> quotes` | `quote` |

### Text Format Bitmask

| Value | Format |
|-------|--------|
| 0 | Normal |
| 1 | Bold |
| 2 | Italic |
| 3 | Bold + Italic |
| 16 | Code |

## Markdown to Lexical Conversion

The skill includes a Python script for converting markdown to Lexical JSON:

```bash
python3 ${SKILL_DIR}/scripts/md_to_lexical.py article.md > /tmp/content.json
```

## Common Collections

| Collection | Slug | Purpose |
|------------|------|---------|
| Posts | `posts` | Blog posts |
| Pages | `pages` | Static pages |
| Media | `media` | Uploaded files |
| Users | `users` | User accounts |

## Local Development Alternative

If working locally and REST auth is problematic, write an inline script in the project:

```typescript
// scripts/create-post.ts
import { getPayload } from 'payload'
import config from '../src/payload.config'

const payload = await getPayload({ config })
await payload.create({
  collection: 'posts',
  data: { title: '...', content: {...}, _status: 'published' }
})
process.exit(0)
```

Run with: `source .env.local && bunx tsx scripts/create-post.ts`

**Note**: If Drizzle prompts for schema migration, answer 'n' and use REST API instead.

## Additional Resources

- **`references/lexical-format.md`** - Complete Lexical node type reference
- **`references/rest-api.md`** - Full REST API documentation
- **`scripts/md_to_lexical.py`** - Markdown to Lexical converter


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### scripts/create-post.ts

```typescript
/**
 * Create a Payload post using the Local API
 *
 * Usage: PAYLOAD_SECRET="..." DATABASE_URI="..." bunx tsx scripts/create-post.ts <title> <slug> <content.json>
 *
 * This script uses Payload's Local API which bypasses REST authentication.
 * Run from within the Payload project directory with proper environment variables.
 */

import { getPayload } from "payload";
import configPromise from "@payload-config";
import { readFileSync } from "node:fs";

async function main() {
  const [title, slug, contentPath] = process.argv.slice(2);

  if (!title || !slug || !contentPath) {
    console.error("Usage: bunx tsx create-post.ts <title> <slug> <content.json>");
    console.error("  title: Post title");
    console.error("  slug: URL slug for the post");
    console.error("  content.json: Path to Lexical JSON content file");
    process.exit(1);
  }

  const payload = await getPayload({ config: configPromise });

  // Check if slug already exists
  const existing = await payload.find({
    collection: "posts",
    where: { slug: { equals: slug } },
    limit: 1,
  });

  if (existing.docs.length > 0) {
    console.error(`Post with slug "${slug}" already exists (ID: ${existing.docs[0].id})`);
    process.exit(1);
  }

  // Read and parse content file
  const contentJson = readFileSync(contentPath, "utf-8");
  const content = JSON.parse(contentJson);

  // Create the post
  const post = await payload.create({
    collection: "posts",
    data: {
      title,
      slug,
      content,
      _status: "published",
    },
  });

  console.log(`Created post: ${post.title}`);
  console.log(`ID: ${post.id}`);
  console.log(`URL: ${process.env.NEXT_PUBLIC_SERVER_URL}/posts/${post.slug}`);
}

main().catch((err) => {
  console.error("Error:", err.message);
  process.exit(1);
});

```

### references/lexical-format.md

```markdown
# Lexical JSON Format Reference

Complete reference for Payload CMS Lexical editor node types.

## Root Structure

Every Lexical document has this structure:

```json
{
  "root": {
    "type": "root",
    "format": "",
    "indent": 0,
    "version": 1,
    "children": [ /* nodes */ ],
    "direction": "ltr"
  }
}
```

## Text Formatting

Text format is a bitmask integer:

| Value | Format |
|-------|--------|
| 0 | Normal |
| 1 | Bold |
| 2 | Italic |
| 3 | Bold + Italic |
| 4 | Strikethrough |
| 8 | Underline |
| 16 | Code |
| 32 | Subscript |
| 64 | Superscript |

Combine values: Bold + Italic = 1 + 2 = 3

## Node Types

### Text Node

```json
{
  "type": "text",
  "text": "Content here",
  "format": 0,
  "style": "",
  "detail": 0,
  "mode": "normal",
  "version": 1
}
```

### Paragraph Node

```json
{
  "type": "paragraph",
  "format": "",
  "indent": 0,
  "version": 1,
  "children": [
    { "type": "text", "text": "...", "format": 0, "version": 1 }
  ],
  "direction": "ltr",
  "textFormat": 0
}
```

### Heading Node

```json
{
  "type": "heading",
  "tag": "h2",
  "format": "",
  "indent": 0,
  "version": 1,
  "children": [
    { "type": "text", "text": "Heading Text", "format": 0, "version": 1 }
  ],
  "direction": "ltr"
}
```

Valid tags: h1, h2, h3, h4, h5, h6

### Link Node

```json
{
  "type": "link",
  "format": "",
  "indent": 0,
  "version": 1,
  "children": [
    { "type": "text", "text": "Link Text", "format": 0, "version": 1 }
  ],
  "direction": "ltr",
  "fields": {
    "url": "https://example.com",
    "newTab": false,
    "linkType": "custom"
  }
}
```

### List Node

```json
{
  "type": "list",
  "listType": "bullet",
  "format": "",
  "indent": 0,
  "version": 1,
  "start": 1,
  "tag": "ul",
  "children": [
    {
      "type": "listitem",
      "format": "",
      "indent": 0,
      "version": 1,
      "value": 1,
      "children": [
        { "type": "text", "text": "Item text", "format": 0, "version": 1 }
      ],
      "direction": "ltr"
    }
  ],
  "direction": "ltr"
}
```

List types:
- `"bullet"` with tag `"ul"` for unordered
- `"number"` with tag `"ol"` for ordered
- `"check"` for checkbox lists

### Quote Node

```json
{
  "type": "quote",
  "format": "",
  "indent": 0,
  "version": 1,
  "children": [
    { "type": "text", "text": "Quoted text", "format": 0, "version": 1 }
  ],
  "direction": "ltr"
}
```

### Horizontal Rule

```json
{
  "type": "horizontalrule",
  "version": 1
}
```

### Code Block (Payload Block)

Payload uses block nodes for code:

```json
{
  "type": "block",
  "format": "",
  "indent": 0,
  "version": 2,
  "fields": {
    "id": "",
    "blockName": "",
    "blockType": "code",
    "code": "const x = 1;\nconsole.log(x);",
    "language": "typescript"
  }
}
```

Common languages: javascript, typescript, python, bash, json, html, css, go, rust

### Media Block

```json
{
  "type": "block",
  "format": "",
  "indent": 0,
  "version": 2,
  "fields": {
    "id": "",
    "blockName": "",
    "blockType": "mediaBlock",
    "media": "media-id-here",
    "position": "default"
  }
}
```

### Banner Block

```json
{
  "type": "block",
  "format": "",
  "indent": 0,
  "version": 2,
  "fields": {
    "id": "",
    "blockName": "",
    "blockType": "banner",
    "style": "info",
    "content": {
      "root": {
        "type": "root",
        "children": [
          {
            "type": "paragraph",
            "children": [
              { "type": "text", "text": "Banner content" }
            ]
          }
        ]
      }
    }
  }
}
```

## Direction Values

- `"ltr"` - Left to right (default)
- `"rtl"` - Right to left

## Complete Example

A document with heading, paragraph, and code:

```json
{
  "root": {
    "type": "root",
    "format": "",
    "indent": 0,
    "version": 1,
    "children": [
      {
        "type": "heading",
        "tag": "h1",
        "format": "",
        "indent": 0,
        "version": 1,
        "children": [
          { "type": "text", "text": "Getting Started", "format": 0, "version": 1 }
        ],
        "direction": "ltr"
      },
      {
        "type": "paragraph",
        "format": "",
        "indent": 0,
        "version": 1,
        "children": [
          { "type": "text", "text": "This is an introduction.", "format": 0, "version": 1 }
        ],
        "direction": "ltr",
        "textFormat": 0
      },
      {
        "type": "block",
        "format": "",
        "indent": 0,
        "version": 2,
        "fields": {
          "id": "",
          "blockName": "",
          "blockType": "code",
          "code": "npm install payload",
          "language": "bash"
        }
      }
    ],
    "direction": "ltr"
  }
}
```

## Validation

When sending to Payload API:
1. Ensure all nodes have `version` field
2. Root must have `type: "root"` and `children` array
3. Block nodes need `fields.blockType` matching collection config
4. Text nodes inside containers (paragraph, heading, list item, etc.)

## INVALID Node Types (Cause Error #17)

These types will crash the frontend with "Minified Lexical error #17":

| Invalid Type | Why | Use Instead |
|--------------|-----|-------------|
| `code` (direct) | Not registered as top-level | `block` with `blockType: "code"` |
| `code-highlight` | Internal Lexical type | Text with `format: 16` |
| `code-line` | Internal type | Regular text nodes |
| Custom nodes | Not registered | Standard nodes only |

### What Causes Error #17

Lexical error #17 means the editor encountered a node type that isn't registered. This happens when:

1. **Direct database edits** use invalid node structures
2. **Draft versions** contain corrupted content from failed edits
3. **Import/migration** scripts produce non-standard JSON

### Fixing Error #17 in Admin Panel

If the admin panel shows error #17 when editing a document:

```sql
-- Find corrupted draft versions
SELECT id, version__status
FROM _posts_v
WHERE parent_id = POST_ID
  AND version_content::text LIKE '%code-highlight%';

-- Delete them
DELETE FROM _posts_v WHERE id IN (corrupted_ids);
```

The admin loads the latest draft version, so corrupted drafts must be removed.

### Safe Direct Database Updates

When updating content directly:

```sql
-- 1. Update the main record with valid Lexical JSON
UPDATE posts
SET content = '{"root": {...}}', updated_at = NOW()
WHERE slug = 'my-post';

-- 2. Delete any draft versions that might have old content
DELETE FROM _posts_v
WHERE parent_id = (SELECT id FROM posts WHERE slug = 'my-post')
  AND version__status = 'draft';
```

Then trigger ISR revalidation via Payload API or by saving in admin.

```

### references/rest-api.md

```markdown
# Payload REST API Reference

For external access when Local API is not available.

## Authentication Options

### Session Token

Obtain via login, use in cookie:

```bash
curl -b "payload-token=YOUR_TOKEN" https://example.com/api/posts
```

### Payload Built-in API Keys

If enabled (`useAPIKey: true` on auth collection):

```bash
curl -H "Authorization: users API-Key YOUR_KEY" https://example.com/api/posts
```

**Note**: Many Payload sites use `payload-auth` or `better-auth` which may disable built-in API keys. Check the site's auth configuration.

## Endpoints

### List Documents

```bash
GET /api/{collection}?limit=100
```

### Get by ID

```bash
GET /api/{collection}/{id}
```

### Query by Field

```bash
GET /api/{collection}?where[field][equals]=value
```

### Create Document

```bash
POST /api/{collection}
Content-Type: application/json

{
  "field": "value",
  "_status": "published"
}
```

### Update Document

```bash
PATCH /api/{collection}/{id}
Content-Type: application/json

{
  "field": "new value"
}
```

### Delete Document

```bash
DELETE /api/{collection}/{id}
```

## Query Operators

| Operator | Example |
|----------|---------|
| equals | `where[status][equals]=published` |
| not_equals | `where[status][not_equals]=draft` |
| like | `where[title][like]=search` |
| contains | `where[tags][contains]=tech` |
| in | `where[category][in]=1,2,3` |
| greater_than | `where[views][greater_than]=100` |
| less_than | `where[views][less_than]=1000` |

## Pagination

```bash
GET /api/posts?limit=10&page=2
```

Response includes:
- `docs`: Array of documents
- `totalDocs`: Total count
- `totalPages`: Number of pages
- `page`: Current page
- `hasNextPage`: Boolean
- `hasPrevPage`: Boolean

## Error Codes

| Code | Meaning |
|------|---------|
| 400 | Invalid JSON or missing required fields |
| 401 | Authentication required |
| 403 | Insufficient permissions |
| 404 | Collection or document not found |
| 500 | Server error |

```

### scripts/md_to_lexical.py

```python
#!/usr/bin/env python3
"""
Convert Markdown to Payload CMS Lexical JSON format.

Usage:
    python3 md_to_lexical.py input.md
    python3 md_to_lexical.py input.md > output.json
    cat input.md | python3 md_to_lexical.py
"""

import sys
import json
import re


def text_node(text: str, format: int = 0) -> dict:
    """Create a text node. Format: 0=normal, 1=bold, 2=italic, 3=bold+italic"""
    return {
        "type": "text",
        "text": text,
        "format": format,
        "style": "",
        "detail": 0,
        "mode": "normal",
        "version": 1
    }


def paragraph_node(children: list) -> dict:
    """Create a paragraph node."""
    return {
        "type": "paragraph",
        "format": "",
        "indent": 0,
        "version": 1,
        "children": children,
        "direction": "ltr",
        "textFormat": 0
    }


def heading_node(text: str, level: int) -> dict:
    """Create a heading node (h1-h6)."""
    return {
        "type": "heading",
        "tag": f"h{level}",
        "format": "",
        "indent": 0,
        "version": 1,
        "children": [text_node(text)],
        "direction": "ltr"
    }


def code_block_node(code: str, language: str = "") -> dict:
    """Create a code block node."""
    return {
        "type": "block",
        "format": "",
        "indent": 0,
        "version": 2,
        "fields": {
            "id": "",
            "blockName": "",
            "blockType": "code",
            "code": code,
            "language": language or "plaintext"
        }
    }


def list_item_node(children: list) -> dict:
    """Create a list item node."""
    return {
        "type": "listitem",
        "format": "",
        "indent": 0,
        "version": 1,
        "value": 1,
        "children": children,
        "direction": "ltr"
    }


def list_node(items: list, ordered: bool = False) -> dict:
    """Create a list node (ul or ol)."""
    return {
        "type": "list",
        "listType": "number" if ordered else "bullet",
        "format": "",
        "indent": 0,
        "version": 1,
        "start": 1,
        "tag": "ol" if ordered else "ul",
        "children": items,
        "direction": "ltr"
    }


def horizontal_rule_node() -> dict:
    """Create a horizontal rule node."""
    return {
        "type": "horizontalrule",
        "version": 1
    }


def parse_inline(text: str) -> list:
    """Parse inline formatting (bold, italic, code, links)."""
    children = []

    # Pattern for inline code, bold, italic
    # Process in order: code first (to avoid conflicts), then bold, then italic
    patterns = [
        (r'`([^`]+)`', lambda m: text_node(m.group(1), 16)),  # code format
        (r'\*\*([^*]+)\*\*', lambda m: text_node(m.group(1), 1)),  # bold
        (r'__([^_]+)__', lambda m: text_node(m.group(1), 1)),  # bold alt
        (r'\*([^*]+)\*', lambda m: text_node(m.group(1), 2)),  # italic
        (r'_([^_]+)_', lambda m: text_node(m.group(1), 2)),  # italic alt
    ]

    # Simple approach: just return plain text for now
    # Complex inline parsing would require more sophisticated tokenization
    if text.strip():
        # Handle basic bold/italic
        text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)  # strip bold markers
        text = re.sub(r'__([^_]+)__', r'\1', text)
        text = re.sub(r'\*([^*]+)\*', r'\1', text)
        text = re.sub(r'_([^_]+)_', r'\1', text)
        text = re.sub(r'`([^`]+)`', r'\1', text)
        children.append(text_node(text))

    return children


def parse_markdown(md: str) -> dict:
    """Parse markdown and return Lexical JSON structure."""
    lines = md.split('\n')
    children = []
    i = 0

    while i < len(lines):
        line = lines[i]

        # Empty line - skip
        if not line.strip():
            i += 1
            continue

        # Heading
        heading_match = re.match(r'^(#{1,6})\s+(.+)$', line)
        if heading_match:
            level = len(heading_match.group(1))
            text = heading_match.group(2)
            children.append(heading_node(text, level))
            i += 1
            continue

        # Code block
        if line.startswith('```'):
            language = line[3:].strip()
            code_lines = []
            i += 1
            while i < len(lines) and not lines[i].startswith('```'):
                code_lines.append(lines[i])
                i += 1
            code = '\n'.join(code_lines)
            children.append(code_block_node(code, language))
            i += 1  # skip closing ```
            continue

        # Horizontal rule
        if re.match(r'^(-{3,}|\*{3,}|_{3,})$', line.strip()):
            children.append(horizontal_rule_node())
            i += 1
            continue

        # Unordered list
        if re.match(r'^[-*+]\s+', line):
            items = []
            while i < len(lines) and re.match(r'^[-*+]\s+', lines[i]):
                item_text = re.sub(r'^[-*+]\s+', '', lines[i])
                items.append(list_item_node(parse_inline(item_text)))
                i += 1
            children.append(list_node(items, ordered=False))
            continue

        # Ordered list
        if re.match(r'^\d+\.\s+', line):
            items = []
            while i < len(lines) and re.match(r'^\d+\.\s+', lines[i]):
                item_text = re.sub(r'^\d+\.\s+', '', lines[i])
                items.append(list_item_node(parse_inline(item_text)))
                i += 1
            children.append(list_node(items, ordered=True))
            continue

        # Regular paragraph - collect until empty line or special block
        para_lines = []
        while i < len(lines):
            current = lines[i]
            # Stop at empty line, heading, code block, list, hr
            if not current.strip():
                break
            if re.match(r'^#{1,6}\s+', current):
                break
            if current.startswith('```'):
                break
            if re.match(r'^[-*+]\s+', current):
                break
            if re.match(r'^\d+\.\s+', current):
                break
            if re.match(r'^(-{3,}|\*{3,}|_{3,})$', current.strip()):
                break
            para_lines.append(current)
            i += 1

        if para_lines:
            para_text = ' '.join(para_lines)
            inline_children = parse_inline(para_text)
            if inline_children:
                children.append(paragraph_node(inline_children))

    return {
        "root": {
            "type": "root",
            "format": "",
            "indent": 0,
            "version": 1,
            "children": children,
            "direction": "ltr"
        }
    }


def main():
    # Read from file argument or stdin
    if len(sys.argv) > 1:
        with open(sys.argv[1], 'r') as f:
            md = f.read()
    else:
        md = sys.stdin.read()

    lexical = parse_markdown(md)
    print(json.dumps(lexical, indent=2))


if __name__ == '__main__':
    main()

```

payload | SkillHub