Sanity CMS
Read and write to the project's Sanity CMS (project: g1sakegy, dataset: production).
All scripts are in .claude/skills/sanity-cms/scripts/. Run from the project root.
Setup
Prerequisites
SANITY_API_TOKEN — project-level token with Editor permissions (for queries + mutations)
SANITY_API_ORG_TOKEN — organization-level token for media library access (optional, used as fallback for uploads)
Set via shell profile or .env.local at project root. The scripts check process.env first, then parse .env.local as fallback.
Install Dependencies
bash
1cd .claude/skills/sanity-cms/scripts && pnpm install
Scripts
query.js — Run GROQ Queries
bash
1# List all categories
2node .claude/skills/sanity-cms/scripts/query.js \
3 --query '*[_type == "category"]|order(title asc){_id, title, slug}'
4
5# Query with parameters
6node .claude/skills/sanity-cms/scripts/query.js \
7 --query '*[_type == "post" && slug.current == $slug][0]{_id, title}' \
8 --params '{"slug": "my-post"}'
9
10# Query from file (avoids shell escaping issues with !, &&, etc.)
11echo '*[_type == "post" && !(_id in path("drafts.**"))]{_id, title}' > /tmp/query.groq
12node .claude/skills/sanity-cms/scripts/query.js --file /tmp/query.groq
13
14# Auto-filter draft documents (no need for ! in the query)
15node .claude/skills/sanity-cms/scripts/query.js \
16 --query '*[_type == "post"]{_id, title}' --no-drafts
Args: --query or --file (one required), --params (optional JSON), --no-drafts (optional, filters out draft documents from array results)
get.js — Get Document by ID
bash
1node .claude/skills/sanity-cms/scripts/get.js --id "abc123"
2node .claude/skills/sanity-cms/scripts/get.js --id "abc123" --fields "title, slug"
Args: --id (required), --fields (optional GROQ projection)
mutate.js — Create, Patch, Delete
bash
1# Create
2node .claude/skills/sanity-cms/scripts/mutate.js --action create \
3 --data '{"_type": "category", "title": "New", "slug": {"_type": "slug", "current": "new"}}'
4
5# Patch (set fields)
6node .claude/skills/sanity-cms/scripts/mutate.js --action patch --id "abc123" \
7 --set '{"title": "Updated"}'
8
9# Patch (unset fields)
10node .claude/skills/sanity-cms/scripts/mutate.js --action patch --id "abc123" \
11 --unset '["subtitle"]'
12
13# Patch (insert into array)
14node .claude/skills/sanity-cms/scripts/mutate.js --action patch --id "abc123" \
15 --insert '{"after": "categories[-1]", "items": [{"_type": "reference", "_ref": "cat-id"}]}'
16
17# Delete
18node .claude/skills/sanity-cms/scripts/mutate.js --action delete --id "abc123"
19
20# Large payload from file
21node .claude/skills/sanity-cms/scripts/mutate.js --action create --file /tmp/doc.json
22
23# Dry run
24node .claude/skills/sanity-cms/scripts/mutate.js --action create --data '...' --dry-run
Args: --action (create|createOrReplace|patch|delete), --data or --file, --id (patch/delete), --set/--unset/--insert (patch), --dry-run
Array items without _key get one auto-generated.
upload.js — Upload Assets
bash
1node .claude/skills/sanity-cms/scripts/upload.js --file /path/to/image.jpg --type image
2node .claude/skills/sanity-cms/scripts/upload.js --file /path/to/doc.pdf --type file
Args: --file (required), --type (image|file), --filename, --label
Returns a ref object ready to embed in mutations.
checklist.js — Generate Processable Checklists
Generates a JSON checklist of readingList items matching filter criteria. Useful for batch processing tasks like recategorization, topic assignment, or auditing.
bash
1# Items NOT in specific categories (by slug)
2node .claude/skills/sanity-cms/scripts/checklist.js \
3 --exclude-categories "damage-control,programming,creative-code" \
4 --output /path/to/checklist.json
5
6# Items IN specific categories only
7node .claude/skills/sanity-cms/scripts/checklist.js \
8 --categories "research-and-understanding,market-forces" \
9 --output /path/to/checklist.json
10
11# Items with a specific topic
12node .claude/skills/sanity-cms/scripts/checklist.js \
13 --topic "ai-coding-agents" \
14 --output /path/to/checklist.json
15
16# Items with NO categories assigned
17node .claude/skills/sanity-cms/scripts/checklist.js \
18 --uncategorized \
19 --output /path/to/checklist.json
20
21# Items with NO topics assigned
22node .claude/skills/sanity-cms/scripts/checklist.js \
23 --no-topics \
24 --output /path/to/checklist.json
25
26# Items missing any metric fields (hnScore, hnCommentCount, sentimentArticle, sentimentCommunity, controversyScore)
27node .claude/skills/sanity-cms/scripts/checklist.js \
28 --no-metrics \
29 --output /path/to/checklist.json
30
31# Items missing article sentiment specifically
32node .claude/skills/sanity-cms/scripts/checklist.js \
33 --no-article-sentiment \
34 --output /path/to/checklist.json
35
36# Items with discussions that need refetching
37node .claude/skills/sanity-cms/scripts/checklist.js \
38 --needs-discussion-refetch \
39 --output /path/to/checklist.json
40
41# Items with discussions last fetched before a specific time (or never fetched)
42node .claude/skills/sanity-cms/scripts/checklist.js \
43 --needs-discussion-refetch \
44 --fetched-before "2026-03-09T00:00:00Z" \
45 --output /path/to/checklist.json
46
47# All published items (no filter)
48node .claude/skills/sanity-cms/scripts/checklist.js \
49 --output /path/to/checklist.json
50
51# Preview without writing
52node .claude/skills/sanity-cms/scripts/checklist.js \
53 --exclude-categories "damage-control" --output checklist.json --dry-run
Args: --output (required), --exclude-categories (optional, comma-separated slugs), --categories (optional, comma-separated slugs), --topic (optional, single slug), --uncategorized (optional flag), --no-topics (optional flag), --no-metrics (optional flag), --no-article-sentiment (optional flag), --needs-discussion-refetch (optional flag, items with a discussionUrl that need refetching), --fetched-before (optional, ISO datetime — items whose discussion was last fetched before this time or never fetched), --limit (optional, max number of items to include in the checklist), --dry-run
--categories and --exclude-categories are mutually exclusive. --topic can be combined with either.
Output file format:
json
1[
2 {
3 "title": "Article Title",
4 "_id": "readingList-slug-timestamp",
5 "currentCategories": ["Category Name"],
6 "currentTopics": ["Topic Name"],
7 "status": "unprocessed"
8 }
9]
slugify.js (lib) — Generate Slugs
A utility in scripts/lib/ for generating Sanity-compatible slugs. Always use this utility when creating or updating slug values — never hand-write slug strings.
CLI Usage
bash
1# Generate a slug string
2node .claude/skills/sanity-cms/scripts/lib/slugify.js "My Blog Post Title"
3# Output: my-blog-post-title
4
5# Generate a full Sanity slug object (JSON)
6node .claude/skills/sanity-cms/scripts/lib/slugify.js "Node.js & TypeScript: A Guide" --json
7# Output: {"_type": "slug", "current": "node-js-typescript-a-guide"}
In-Script Usage
javascript
1import { slugify, toSlugObject } from './lib/slugify.js';
2
3const slug = slugify('My Blog Post Title'); // "my-blog-post-title"
4const slugField = toSlugObject('My Blog Post Title'); // {"_type": "slug", "current": "my-blog-post-title"}
Slugify Rules
- Lowercase
- Spaces → hyphens
- Periods → hyphens (periods are reserved in Sanity IDs for draft status)
- Remove all characters except
a-z, 0-9, -
- Collapse consecutive hyphens
- Trim leading/trailing hyphens
- Max length: 200 characters
All scripts output JSON. Success to stdout, errors to stderr:
json
1{ "success": true, ... }
2{ "success": false, "error": "message" }
Common Workflows
Create a Blog Post
bash
1# 1. Upload the main image
2node .claude/skills/sanity-cms/scripts/upload.js --file /path/to/hero.jpg --type image
3# Note the assetId from output
4
5# 2. Find author and category IDs
6node .claude/skills/sanity-cms/scripts/query.js --query '*[_type == "author"][0]{_id, name}'
7node .claude/skills/sanity-cms/scripts/query.js --query '*[_type == "category" && slug.current == "programming"][0]{_id}'
8
9# 3. Create the post (write large payload to file first)
10node .claude/skills/sanity-cms/scripts/mutate.js --action create --file /tmp/new-post.json
Add a Reading List Item
bash
1# 1. Generate slug from title
2SLUG=$(node .claude/skills/sanity-cms/scripts/lib/slugify.js "Article Title")
3
4# 2. Find or create category IDs
5node .claude/skills/sanity-cms/scripts/query.js \
6 --query '*[_type == "category" && title in ["Programming", "Research & Understanding"]]{_id, title}'
7
8# 3. Create the reading list item
9node .claude/skills/sanity-cms/scripts/mutate.js --action create --data '{
10 "_type": "readingList",
11 "title": "Article Title",
12 "slug": {"_type": "slug", "current": "'"$SLUG"'"},
13 "originalUrl": "https://example.com/article",
14 "savedAt": "2026-02-26T00:00:00Z",
15 "categories": [
16 {"_type": "reference", "_ref": "category-id-1"},
17 {"_type": "reference", "_ref": "category-id-2"}
18 ],
19 "gist": "One sentence summary",
20 "shortSummary": "Three sentence summary here."
21}'
Update an Existing Document
bash
1# Set fields
2node .claude/skills/sanity-cms/scripts/mutate.js --action patch --id "doc-id" \
3 --set '{"description": "Updated description"}'
4
5# Remove fields
6node .claude/skills/sanity-cms/scripts/mutate.js --action patch --id "doc-id" \
7 --unset '["intro"]'
Document Types
Five document types: post, readingList, category, thing, author.
For full field definitions, Portable Text construction examples, GROQ patterns, and reference/slug/image formats, see references/schema.md.
Key Conventions
- Slugs:
{"_type": "slug", "current": "kebab-case-value"} — always generate with slugify.js, never hand-write
- References:
{"_type": "reference", "_ref": "document-id"}
- Array items: Must have
_key (auto-generated by mutate.js if missing)
- Images: Upload first with upload.js, then use the returned
ref in the image field's asset property
- Document IDs: Limited to 128 characters by Sanity. Auto-generated UUIDs (~36 chars) are always safe. When providing an explicit
_id (e.g., for createOrReplace), keep it under 128 characters. mutate.js validates this before sending to the API.
- Block content: Portable Text format — see schema reference for JSON structure
Avoiding ! in GROQ Queries
Zsh treats ! as history expansion, which breaks GROQ queries containing negation. Never use ! in inline --query strings. Instead:
- Filtering drafts: Use
--no-drafts flag instead of !(_id in path("drafts.**"))
- GROQ negation: Use
defined(x) == false instead of !defined(x)
- Complex queries: Use
--file to read the query from a file, bypassing shell escaping entirely
bash
1# BAD — will break in zsh
2--query '*[_type == "post" && !defined(topics)]'
3
4# GOOD — inline-safe alternative
5--query '*[_type == "post" && defined(topics) == false]' --no-drafts
6
7# GOOD — file-based for complex queries with unavoidable !
8node .claude/skills/sanity-cms/scripts/query.js --file /tmp/query.groq
Limitations
- Mux videos cannot be uploaded via this skill. They go through the Sanity Studio Mux plugin.
- Document ID length — Sanity limits
_id to 128 characters. mutate.js rejects IDs that exceed this limit. If you construct IDs from slugs or other dynamic values, ensure the result stays under 128 characters.
- Transactions are not supported. For multi-document atomic operations, compose mutations manually using the Sanity client API directly.