From d461ce1d8ecc1867af6a582a882e21a5752fde17 Mon Sep 17 00:00:00 2001 From: Brian LeRoux Date: Fri, 6 Mar 2026 14:11:53 -0800 Subject: [PATCH 1/6] hbd: new function skills --- AGENTS.md | 1 + package-lock.json | 2 +- package.json | 3 +- skills/sanity-functions/SKILL.md | 343 +++++++++++++++ .../references/blueprint-config.md | 206 ++++++++++ .../sanity-functions/references/patterns.md | 389 ++++++++++++++++++ 6 files changed, 942 insertions(+), 2 deletions(-) create mode 100644 skills/sanity-functions/SKILL.md create mode 100644 skills/sanity-functions/references/blueprint-config.md create mode 100644 skills/sanity-functions/references/patterns.md diff --git a/AGENTS.md b/AGENTS.md index 4467b4a..6de1158 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ If the Sanity MCP server (`https://mcp.sanity.io`) is available, use `list_sanit | **TypeGen** | `typegen`, `typescript`, `types`, `infer`, `satisfies`, `type generation` | `skills/sanity-best-practices/references/typegen.md` | | **App SDK** | `app sdk`, `custom app`, `useDocuments`, `useDocument`, `DocumentHandle`, `SanityApp`, `sdk-react` | `skills/sanity-best-practices/references/app-sdk.md` | | **Blueprints** | `blueprints`, `IaC`, `infrastructure`, `stack`, `defineBlueprint` | `skills/sanity-best-practices/references/blueprints.md` | +| **Sanity Functions** | `functions`, `serverless`, `event handler`, `documentEventHandler`, `defineDocumentFunction`, `sanity.blueprint.ts` | `skills/sanity-functions/SKILL.md` | ### Using the Knowledge Router diff --git a/package-lock.json b/package-lock.json index 1af3d15..5826ec2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "devDependencies": { - "skills-ref": "^0.1.5" + "skills-ref": "0.1.5" } }, "node_modules/argparse": { diff --git a/package.json b/package.json index e6b3108..31a90fc 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,8 @@ "description": "AI agent resources for Sanity development", "private": true, "scripts": { - "validate": "skills-ref validate ./skills/sanity-best-practices && skills-ref validate ./skills/content-modeling-best-practices && skills-ref validate ./skills/seo-aeo-best-practices && skills-ref validate ./skills/content-experimentation-best-practices", + "validate": "skills-ref validate ./skills/sanity-best-practices && skills-ref validate ./skills/content-modeling-best-practices && skills-ref validate ./skills/seo-aeo-best-practices && skills-ref validate ./skills/content-experimentation-best-practices && skills-ref validate ./skills/sanity-functions", + "validate:sanity-functions": "skills-ref validate ./skills/sanity-functions", "validate:sanity": "skills-ref validate ./skills/sanity-best-practices", "validate:content-modeling": "skills-ref validate ./skills/content-modeling-best-practices", "validate:seo": "skills-ref validate ./skills/seo-aeo-best-practices", diff --git a/skills/sanity-functions/SKILL.md b/skills/sanity-functions/SKILL.md new file mode 100644 index 0000000..8900b25 --- /dev/null +++ b/skills/sanity-functions/SKILL.md @@ -0,0 +1,343 @@ +--- +name: sanity-functions +description: Guide for creating, testing, and deploying Sanity Functions — serverless event handlers that react to content changes in Sanity's Content Lake. Use this skill whenever the user mentions Sanity Functions, Blueprints for Functions, event-driven content automation, document event handlers, content workflows with Sanity, or wants to write code that runs when Sanity documents are created, updated, deleted, or published. Also trigger when the user mentions @sanity/functions, @sanity/blueprints, documentEventHandler, defineDocumentFunction, defineMediaLibraryAssetFunction, or sanity.blueprint.ts. Even if the user just says "I want to run code when content changes in Sanity" or "automate something on publish," use this skill. +--- + +# Sanity Functions Development Guide + +## Overview + +Sanity Functions are serverless event handlers hosted on Sanity's infrastructure. They execute custom logic when content changes occur — no infrastructure management required. Functions are configured via **Blueprints** (declarative resource definitions) and triggered by document lifecycle events. + +> **Experimental feature**: APIs are subject to change. Always use the latest Sanity CLI (`npx sanity@latest`). + +## When to use Functions + +- Enrich, validate, or constrain content on publish +- Trigger external services (CDN purge, deploy hooks, notifications) +- Automate workflows (translation, tagging, cross-posting) +- Sync content to external systems (Algolia, Elasticsearch) +- Set computed/derived fields (timestamps, slugs, summaries) +- Invoke Agent Actions (Generate, Transform, Translate) in response to content events + +## Requirements + +- **Node.js v22.x** — matches the deployed runtime +- **Sanity CLI v4.12.0+** — use `npx sanity@latest` for latest +- **@sanity/blueprints** — for blueprint configuration helpers +- **@sanity/functions** — for handler types and the `documentEventHandler` wrapper +- **@sanity/client v7.12.0+** — includes recursion protection via lineage headers + +--- + +## Architecture + +### How it works + +1. Content changes in Sanity's Content Lake emit events +2. A Blueprint configuration defines which events trigger which functions (using GROQ filters) +3. The function handler receives `context` (client config, metadata) and `event` (document data) +4. The function executes custom logic — patching documents, calling APIs, invoking Agent Actions + +### Project structure + +Organize functions alongside your Sanity project, one level above the Studio directory: + +``` +my-project/ +├── studio/ +├── next-app/ +├── functions/ +│ ├── my-function/ +│ │ ├── index.ts # Handler code (entry point) +│ │ └── package.json # (optional) function-level dependencies +│ └── another-function/ +│ └── index.ts +├── sanity.blueprint.ts # Blueprint configuration +├── package.json # Project-level dependencies +└── node_modules/ +``` + +The function directory name must match the `name` in the blueprint config. Each function exports a `handler` from its `index.ts` (or `index.js`). + +--- + +## Step-by-step: Creating a Function + +### 1. Initialize a Blueprint + +From your project root (above the studio directory): + +```bash +npx sanity@latest blueprints init . \ + --type ts \ + --stack-name production \ + --project-id +``` + +This creates `sanity.blueprint.ts` and `blueprint.config.ts`. + +### 2. Add a Function + +```bash +npx sanity@latest blueprints add function \ + --name my-function \ + --fn-type document-publish \ + --installer npm +``` + +Options for `--fn-type`: `document-create`, `document-update`, `document-publish`, `document-delete`. + +This scaffolds `functions/my-function/index.ts` and prompts you to update the blueprint file. + +### 3. Configure the Blueprint + +```typescript +// sanity.blueprint.ts +import { defineBlueprint, defineDocumentFunction } from '@sanity/blueprints' + +export default defineBlueprint({ + resources: [ + defineDocumentFunction({ + name: 'my-function', + // Optional overrides: + // src: 'functions/my-function', // inferred from name if omitted + // memory: 1, // GB, default 1, max 10 + // timeout: 10, // seconds, default 10, max 900 + event: { + on: ['create', 'update'], + filter: '_type == "post"', + // projection: '{title, _id, _type, slug}', + // includeDrafts: false, // default false + // includeAllVersions: false, // default false + // resource: { type: 'dataset', id: 'projectId.datasetName' }, + }, + // env: { MY_VAR: 'value' }, + }), + ], +}) +``` + +### 4. Write the Handler + +**TypeScript (recommended):** + +```typescript +// functions/my-function/index.ts +import { documentEventHandler } from '@sanity/functions' +import { createClient } from '@sanity/client' + +interface PostData { + _id: string + _type: string + title: string +} + +export const handler = documentEventHandler(async ({ context, event }) => { + const { data } = event + + const client = createClient({ + ...context.clientOptions, + apiVersion: '2025-05-08', + }) + + try { + await client.patch(data._id, { + setIfMissing: { firstPublished: new Date().toISOString() }, + }) + console.log(`Set firstPublished on ${data._id}`) + } catch (error) { + console.error('Failed to patch document:', error) + } +}) +``` + +**JavaScript alternative:** + +```javascript +// functions/my-function/index.js +import { createClient } from '@sanity/client' + +export async function handler({ context, event }) { + const { data } = event + const client = createClient({ + ...context.clientOptions, + apiVersion: '2025-05-08', + }) + + // your logic here +} +``` + +### 5. Test Locally + +**Development playground (visual UI):** + +```bash +npx sanity@latest functions dev +# Opens http://localhost:8080 +``` + +**CLI testing:** + +```bash +npx sanity@latest functions test my-function \ + --dataset production \ + --with-user-token + +# Or with a specific document: +npx sanity@latest functions test my-function \ + --document-id abc123 \ + --dataset production \ + --with-user-token + +# Or with a file: +npx sanity@latest functions test my-function \ + --file sample-document.json +``` + +### 6. Deploy + +```bash +npx sanity@latest blueprints deploy +``` + +### 7. View Logs + +```bash +npx sanity@latest functions logs my-function +# Stream in real-time: +npx sanity@latest functions logs my-function --watch +``` + +### 8. Destroy (cleanup) + +```bash +npx sanity@latest blueprints destroy +``` + +--- + +## Handler Reference + +Every handler receives `{ context, event }`: + +### `context` + +```typescript +{ + clientOptions: { + apiHost: 'https://api.sanity.io', + projectId: 'abc123', + dataset: 'production', + token: '***' // robot token, available in deployed functions + }, + local: boolean | undefined, // true during `functions test` / `functions dev` + eventResourceType: string, // 'dataset' or 'media-library' + eventResourceId: string, // e.g., 'projectId.datasetName' or ML ID +} +``` + +### `event` + +```typescript +{ + data: { + _id: string, + _type: string, + // ... rest of document (shaped by projection if set) + } +} +``` + +When testing locally, `context.clientOptions` only has `projectId` and `apiHost`. Use `--dataset` and `--with-user-token` flags to supply the rest. + +--- + +## Blueprint Configuration Reference + +See `references/blueprint-config.md` for the full configuration reference including all `defineDocumentFunction` and `defineMediaLibraryAssetFunction` options. + +## Common Patterns + +See `references/patterns.md` for ready-to-use patterns including: CDN invalidation, auto-translation with Agent Actions, setting computed fields, dataset scoping, Media Library functions, draft/version targeting, and recursion control. + +--- + +## Critical Guidance + +### Preventing recursion + +If your function mutates the same document type it listens to, you **will** create an infinite loop. Prevent this by: + +1. **Use GROQ filters** to exclude already-processed documents: `_type == 'post' && !defined(firstPublished)` +2. **Use `@sanity/client` v7.12.0+** which automatically sets the `X-Sanity-Lineage` header for recursion detection +3. **Create drafts/versions** instead of writing to published documents directly +4. Rate limits apply: 200 invocations per document per 30s, 4000 per project per 30s + +### Keeping functions small + +- Max size: 200MB (including dependencies) +- Prefer slim, platform-agnostic packages — no native code wrappers +- Split large logic across multiple functions +- Larger functions = slower cold starts + +### Environment variables + +- Access via `process.env.MY_VAR` +- Set in blueprint config: `env: { MY_VAR: 'value' }` +- Or via CLI: `npx sanity functions env add my-function MY_VAR my-value` +- For local testing, prepend to the command: `MY_VAR=value npx sanity functions test my-function` + +### Local testing safety + +Use `context.local` to prevent accidental mutations during testing: + +```typescript +if (!context.local) { + await client.createOrReplace(doc) +} +// Or use dryRun: +await client.patch(id, ops).commit({ dryRun: context.local }) +``` + +### Projections + +- Projections shape the data passed to `event.data` +- Limited to the invoking document's scope (plus `→` for references) +- Nested filters in projections (like `*[references(^._id)]`) will fail silently — query inside the function instead +- Wrap projection in `{}` to receive an object: `projection: '{title, _id, slug}'` + +### GROQ filter tips + +- Only include the filter body, not the wrapping `*[...]` +- Use `_type == 'post'` not `*[_type == 'post']` +- Use `delta::changedAny(fieldName)` to trigger only when specific fields change +- Use `sanity::dataset() == 'production'` to scope to a dataset without the `resource` config +- Use `_id in path('drafts.**')` with `includeDrafts: true` for draft-only triggers + +### Event types + +- `create` — new document created +- `update` — existing document modified +- `delete` — document deleted +- `publish` — cannot combine with other event types +- For published documents (default), `update` only fires when a draft/version is published. Often better to use `['create', 'update']` together. + +### Cost considerations + +Cost = invocations × (memory GB × duration seconds). Default is 1GB memory. A function averaging 1GB and 40ms duration can run ~500k invocations within 20K GB-seconds. Monitor usage at the organization level. + +--- + +## CI/CD Deployment + +Use the [Blueprints GitHub Action](https://github.com/sanity-io/blueprints-action) to deploy on merge: + +```yaml +- uses: sanity-io/blueprints-action@v1 + with: + sanity-token: ${{ secrets.SANITY_DEPLOY_TOKEN }} +``` + +Only personal auth tokens are supported for deployment (not robot tokens). diff --git a/skills/sanity-functions/references/blueprint-config.md b/skills/sanity-functions/references/blueprint-config.md new file mode 100644 index 0000000..b8b2cbc --- /dev/null +++ b/skills/sanity-functions/references/blueprint-config.md @@ -0,0 +1,206 @@ +# Blueprint Configuration Reference + +Complete reference for configuring Sanity Functions via Blueprints. Blueprints are declarative resource definitions in `sanity.blueprint.ts` that tell Sanity which functions to deploy and how to trigger them. + +## Setup + +### Initialize a Blueprint + +```bash +npx sanity@latest blueprints init . \ + --type ts \ + --stack-name production \ + --project-id +``` + +Creates `sanity.blueprint.ts` and `blueprint.config.ts` in your project root. + +### Scaffold a Function + +```bash +npx sanity@latest blueprints add function \ + --name my-function \ + --fn-type document-publish \ + --installer npm +``` + +`--fn-type` options: `document-create`, `document-update`, `document-publish`, `document-delete`. + +--- + +## `defineBlueprint` + +Top-level wrapper. Takes a `resources` array of function definitions. + +```typescript +import { defineBlueprint, defineDocumentFunction } from '@sanity/blueprints' + +export default defineBlueprint({ + resources: [ + // one or more function definitions + ], +}) +``` + +--- + +## `defineDocumentFunction` + +Defines a function triggered by document lifecycle events. + +```typescript +defineDocumentFunction({ + name: 'my-function', + src: 'functions/my-function', // optional, inferred from name + memory: 1, // GB, default 1, max 10 + timeout: 10, // seconds, default 10, max 900 + event: { + on: ['create', 'update'], + filter: '_type == "post"', + projection: '{title, _id, _type, slug}', + includeDrafts: false, // default false + includeAllVersions: false, // default false + resource: { + type: 'dataset', + id: 'projectId.datasetName', + }, + }, + env: { + MY_VAR: 'value', + }, +}) +``` + +### Options + +| Option | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `name` | `string` | required | Function name. Must match the directory name under `functions/`. | +| `src` | `string` | `functions/` | Path to the function source directory. | +| `memory` | `number` | `1` | Memory allocation in GB. Max 10. | +| `timeout` | `number` | `10` | Execution timeout in seconds. Max 900. | +| `event` | `object` | required | Event configuration (see below). | +| `env` | `Record` | — | Environment variables available via `process.env`. | + +### `event` Options + +| Option | Type | Default | Description | +| :--- | :--- | :--- | :--- | +| `on` | `string[]` | required | Event types: `'create'`, `'update'`, `'delete'`, `'publish'`. `'publish'` cannot be combined with others. | +| `filter` | `string` | — | GROQ filter expression (body only, no `*[...]` wrapper). | +| `projection` | `string` | — | GROQ projection to shape `event.data`. Wrap in `{}`. | +| `includeDrafts` | `boolean` | `false` | Whether to trigger on draft document changes. | +| `includeAllVersions` | `boolean` | `false` | Whether to trigger on all document versions. | +| `resource` | `object` | — | Scope to a specific dataset: `{ type: 'dataset', id: 'projectId.datasetName' }`. | + +--- + +## `defineMediaLibraryAssetFunction` + +Defines a function triggered by Media Library asset events. Requires `@sanity/blueprints` v0.4.0+ and `@sanity/functions` v1.1.0+. + +```typescript +import { defineBlueprint, defineMediaLibraryAssetFunction } from '@sanity/blueprints' + +export default defineBlueprint({ + resources: [ + defineMediaLibraryAssetFunction({ + name: 'asset-handler', + event: { + on: ['delete'], + filter: 'documents::incomingGlobalDocumentReferenceCount() > 0', + projection: '{_id, versions, title}', + resource: { + type: 'media-library', + id: 'mlYourLibraryId', + }, + }, + }), + ], +}) +``` + +### `resource` for Media Library + +| Option | Type | Description | +| :--- | :--- | :--- | +| `type` | `'media-library'` | Must be `'media-library'`. | +| `id` | `string` | The Media Library ID (e.g., `'mlYourLibraryId'`). | + +All other options (`name`, `src`, `memory`, `timeout`, `env`, `event.on`, `event.filter`, `event.projection`) behave the same as `defineDocumentFunction`. + +--- + +## Multiple Functions + +A single blueprint can define multiple functions: + +```typescript +export default defineBlueprint({ + resources: [ + defineDocumentFunction({ + name: 'first-published', + event: { + on: ['create', 'update'], + filter: "_type == 'post' && !defined(firstPublished)", + }, + }), + defineDocumentFunction({ + name: 'notify-slack', + event: { + on: ['create', 'update'], + filter: "_type == 'post'", + projection: '{title, _id}', + }, + }), + defineDocumentFunction({ + name: 'sync-algolia', + timeout: 30, + event: { + on: ['create', 'update', 'delete'], + filter: "_type == 'product'", + }, + }), + ], +}) +``` + +Each function needs its own directory under `functions/`. + +--- + +## Environment Variables + +Three ways to set them: + +1. In the blueprint config: `env: { MY_VAR: 'value' }` +2. Via CLI: `npx sanity functions env add my-function MY_VAR my-value` +3. For local testing: `MY_VAR=value npx sanity functions test my-function` + +Access in handler code via `process.env.MY_VAR`. + +--- + +## Deployment & Lifecycle + +```bash +# Deploy all functions defined in the blueprint +npx sanity@latest blueprints deploy + +# View logs +npx sanity@latest functions logs my-function +npx sanity@latest functions logs my-function --watch + +# Tear down all deployed resources +npx sanity@latest blueprints destroy +``` + +--- + +## GROQ Filter Tips + +- Only the filter body — use `_type == 'post'`, not `*[_type == 'post']` +- `delta::changedAny(fieldName)` — trigger only when specific fields change +- `sanity::dataset() == 'production'` — scope to a dataset without `resource` config +- `_id in path('drafts.**')` with `includeDrafts: true` — draft-only triggers +- Combine conditions to prevent recursion: `_type == 'post' && !defined(processedAt)` diff --git a/skills/sanity-functions/references/patterns.md b/skills/sanity-functions/references/patterns.md new file mode 100644 index 0000000..b59b1e7 --- /dev/null +++ b/skills/sanity-functions/references/patterns.md @@ -0,0 +1,389 @@ +# Common Function Patterns + +Ready-to-use patterns for Sanity Functions. Each includes the blueprint config and handler code. + +--- + +## 1. Ping a deploy hook / invalidate CDN + +Triggers an external URL when content publishes. Works for Vercel, Netlify, Cloudflare, etc. + +**Blueprint:** +```typescript +defineDocumentFunction({ + name: 'deploy-hook', + event: { + on: ['create', 'update'], + filter: '_type == "page"', + }, +}) +``` + +**Handler:** +```typescript +import { documentEventHandler } from '@sanity/functions' + +export const handler = documentEventHandler(async ({ context, event }) => { + const URL = process.env.DEPLOY_HOOK_URL + if (!URL) throw new Error('DEPLOY_HOOK_URL is not set') + + try { + await fetch(URL) + console.log('Deploy hook triggered successfully') + } catch (error) { + console.error('Failed to trigger deploy hook:', error) + } +}) +``` + +Set the env var: `npx sanity functions env add deploy-hook DEPLOY_HOOK_URL https://...` + +--- + +## 2. Set a timestamp on first publish + +Sets a `firstPublished` field once, never overwriting it. + +**Blueprint:** +```typescript +defineDocumentFunction({ + name: 'first-published', + event: { + on: ['create', 'update'], + filter: '_type == "post" && !defined(firstPublished)', + }, +}) +``` + +**Handler:** +```typescript +import { documentEventHandler } from '@sanity/functions' +import { createClient } from '@sanity/client' + +interface PostData { _id: string } + +export const handler = documentEventHandler(async ({ context, event }) => { + const client = createClient({ + ...context.clientOptions, + apiVersion: '2025-05-08', + }) + + try { + await client.patch(event.data._id, { + setIfMissing: { firstPublished: new Date().toISOString() }, + }) + console.log(`firstPublished set on ${event.data._id}`) + } catch (error) { + console.error(error) + } +}) +``` + +The `!defined(firstPublished)` filter prevents the function from running again after the field is set. The `setIfMissing` patch is a redundant safety net. + +--- + +## 3. Auto-translate with Agent Actions + +Translates documents automatically when published in a source language. + +**Blueprint:** +```typescript +defineDocumentFunction({ + name: 'translate', + event: { + on: ['create', 'update'], + filter: "_type == 'post' && language == 'en-US'", + projection: '{_id}', + }, +}) +``` + +**Handler:** +```typescript +import { documentEventHandler } from '@sanity/functions' +import { createClient } from '@sanity/client' + +export const handler = documentEventHandler(async ({ context, event }) => { + const { data } = event + const client = createClient({ + ...context.clientOptions, + apiVersion: 'vX', + }) + + const targetLanguage = { id: 'el-GR', title: 'Greek' } + const targetId = `${data._id}-${targetLanguage.id}` + + try { + await client.agent.action.translate({ + schemaId: 'your-schema-id', + async: true, + documentId: data._id, + languageFieldPath: 'language', + targetDocument: { + operation: 'createOrReplace', + _id: targetId, + }, + fromLanguage: { id: 'en-US', title: 'English' }, + toLanguage: targetLanguage, + }) + console.log(`Translation triggered for ${data._id}`) + } catch (error) { + console.error(error) + } +}) +``` + +The GROQ filter ensures only English documents trigger the function. The translated document gets a different `language` value, preventing recursive triggers. + +--- + +## 4. Scope to a specific dataset + +**Option A: Using the `resource` config:** +```typescript +defineDocumentFunction({ + name: 'production-only', + event: { + on: ['update'], + filter: "_type == 'post'", + resource: { + type: 'dataset', + id: 'myProjectId.production', + }, + }, +}) +``` + +**Option B: Using a GROQ filter:** +```typescript +defineDocumentFunction({ + name: 'production-only', + event: { + on: ['update'], + filter: "_type == 'post' && sanity::dataset() == 'production'", + }, +}) +``` + +--- + +## 5. React to Media Library asset changes + +Requires `@sanity/blueprints` v0.4.0+ and `@sanity/functions` v1.1.0+. + +**Blueprint:** +```typescript +import { defineBlueprint, defineMediaLibraryAssetFunction } from '@sanity/blueprints' + +export default defineBlueprint({ + resources: [ + defineMediaLibraryAssetFunction({ + name: 'asset-deleted', + event: { + on: ['delete'], + filter: 'documents::incomingGlobalDocumentReferenceCount() > 0', + projection: '{_id, versions, title}', + resource: { + type: 'media-library', + id: 'mlYourLibraryId', + }, + }, + }), + ], +}) +``` + +**Handler (accessing ML with client):** +```typescript +import { documentEventHandler } from '@sanity/functions' +import { createClient } from '@sanity/client' + +export const handler = documentEventHandler(async ({ context, event }) => { + const { eventResourceId } = context // Media Library ID + const client = createClient({ + ...context.clientOptions, + apiVersion: '2025-05-08', + }) + + const response = await client.request({ + uri: `/media-libraries/${eventResourceId}/query`, + method: 'POST', + body: { query: `*[_type == 'sanity.imageAsset']` }, + }) + + console.log('Assets:', response) +}) +``` + +--- + +## 6. Send a Slack notification on publish + +**Handler:** +```typescript +import { documentEventHandler } from '@sanity/functions' + +export const handler = documentEventHandler(async ({ context, event }) => { + const WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL + if (!WEBHOOK_URL) throw new Error('SLACK_WEBHOOK_URL not set') + + const { data } = event + + try { + await fetch(WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `📝 New content published: *${data.title || data._id}* (${data._type})`, + }), + }) + console.log('Slack notification sent') + } catch (error) { + console.error('Failed to send Slack notification:', error) + } +}) +``` + +--- + +## 7. Prevent accidental writes during local testing + +Use `context.local` to guard mutations: + +```typescript +import { documentEventHandler } from '@sanity/functions' +import { createClient } from '@sanity/client' + +export const handler = documentEventHandler(async ({ context, event }) => { + const client = createClient({ + ...context.clientOptions, + apiVersion: '2025-05-08', + }) + + // Approach 1: Skip mutations entirely in test + if (!context.local) { + await client.createOrReplace(someDoc) + } + + // Approach 2: Use dryRun + await client.patch(event.data._id, { + set: { processed: true }, + }).commit({ dryRun: context.local }) + + // Approach 3: Agent Actions with noWrite + await client.agent.action.generate({ + schemaId: 'your-schema-id', + documentId: event.data._id, + instruction: 'Summarize this document', + target: { path: ['summary'] }, + noWrite: context.local, + }) +}) +``` + +--- + +## 8. Auto-tag content with Agent Actions + +**Blueprint:** +```typescript +defineDocumentFunction({ + name: 'auto-tag', + event: { + on: ['create', 'update'], + filter: "_type == 'post'", + projection: '{_id, title, body}', + }, +}) +``` + +**Handler:** +```typescript +import { documentEventHandler } from '@sanity/functions' +import { createClient } from '@sanity/client' + +export const handler = documentEventHandler(async ({ context, event }) => { + const client = createClient({ + ...context.clientOptions, + apiVersion: 'vX', + }) + + try { + await client.agent.action.generate({ + schemaId: 'your-schema-id', + documentId: event.data._id, + instruction: 'Analyze the content and generate 3 relevant tags. Reuse existing tags when possible.', + target: { path: ['tags'] }, + async: true, + }) + console.log(`Auto-tagging triggered for ${event.data._id}`) + } catch (error) { + console.error(error) + } +}) +``` + +--- + +## 9. Enable recursion control with custom HTTP clients + +If not using `@sanity/client`, implement lineage tracking manually: + +```typescript +import { documentEventHandler } from '@sanity/functions' + +export const handler = documentEventHandler(async ({ context, event }) => { + const lineage = process.env.X_SANITY_LINEAGE + + await fetch(`https://${context.clientOptions.projectId}.api.sanity.io/v2025-05-08/data/mutate/production`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${context.clientOptions.token}`, + ...(lineage ? { 'X-Sanity-Lineage': lineage } : {}), + }, + body: JSON.stringify({ + mutations: [{ patch: { id: event.data._id, set: { processed: true } } }], + }), + }) +}) +``` + +--- + +## 10. Multiple functions in one blueprint + +```typescript +import { defineBlueprint, defineDocumentFunction } from '@sanity/blueprints' + +export default defineBlueprint({ + resources: [ + defineDocumentFunction({ + name: 'first-published', + event: { + on: ['create', 'update'], + filter: "_type == 'post' && !defined(firstPublished)", + }, + }), + defineDocumentFunction({ + name: 'notify-slack', + event: { + on: ['create', 'update'], + filter: "_type == 'post'", + projection: '{title, _id}', + }, + }), + defineDocumentFunction({ + name: 'sync-algolia', + timeout: 30, + event: { + on: ['create', 'update', 'delete'], + filter: "_type == 'product'", + }, + }), + ], +}) +``` + +Each function gets its own directory under `functions/`. From 8513be4973f86ae978fa3f998573dbf66962c4c7 Mon Sep 17 00:00:00 2001 From: Brian LeRoux Date: Fri, 6 Mar 2026 14:21:16 -0800 Subject: [PATCH 2/6] fix(sanity-functions): align skill with official docs - Mark publish event as deprecated (equivalent to create+update) - Add 16-invocation recursion chain limit - Fix rate limit wording: per function, not per document - Add missing defineDocumentFunction options: displayName, runtime, project, robotToken - Replace blueprint-config.md with dedicated config reference --- skills/sanity-functions/SKILL.md | 10 ++++++---- .../references/blueprint-config.md | 20 +++++++++++++------ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/skills/sanity-functions/SKILL.md b/skills/sanity-functions/SKILL.md index 8900b25..7def69f 100644 --- a/skills/sanity-functions/SKILL.md +++ b/skills/sanity-functions/SKILL.md @@ -86,7 +86,7 @@ npx sanity@latest blueprints add function \ --installer npm ``` -Options for `--fn-type`: `document-create`, `document-update`, `document-publish`, `document-delete`. +Options for `--fn-type`: `document-create`, `document-update`, `document-publish` (deprecated), `document-delete`. This scaffolds `functions/my-function/index.ts` and prompts you to update the blueprint file. @@ -101,9 +101,11 @@ export default defineBlueprint({ defineDocumentFunction({ name: 'my-function', // Optional overrides: + // displayName: 'My Function', // human-readable name // src: 'functions/my-function', // inferred from name if omitted // memory: 1, // GB, default 1, max 10 // timeout: 10, // seconds, default 10, max 900 + // runtime: 'nodejs22.x', // 'node' | 'nodejs22.x' | 'nodejs24.x' event: { on: ['create', 'update'], filter: '_type == "post"', @@ -271,9 +273,9 @@ See `references/patterns.md` for ready-to-use patterns including: CDN invalidati If your function mutates the same document type it listens to, you **will** create an infinite loop. Prevent this by: 1. **Use GROQ filters** to exclude already-processed documents: `_type == 'post' && !defined(firstPublished)` -2. **Use `@sanity/client` v7.12.0+** which automatically sets the `X-Sanity-Lineage` header for recursion detection +2. **Use `@sanity/client` v7.12.0+** which automatically sets the `X-Sanity-Lineage` header for recursion detection. Recursive chains are limited to 16 invocations. 3. **Create drafts/versions** instead of writing to published documents directly -4. Rate limits apply: 200 invocations per document per 30s, 4000 per project per 30s +4. Rate limits apply: 200 invocations per function per 30s, 4000 per project per 30s ### Keeping functions small @@ -321,7 +323,7 @@ await client.patch(id, ops).commit({ dryRun: context.local }) - `create` — new document created - `update` — existing document modified - `delete` — document deleted -- `publish` — cannot combine with other event types +- `publish` — **deprecated**, equivalent to `on: ['create', 'update']`. Migrate existing `on: ['publish']` to explicit `create`/`update` events. - For published documents (default), `update` only fires when a draft/version is published. Often better to use `['create', 'update']` together. ### Cost considerations diff --git a/skills/sanity-functions/references/blueprint-config.md b/skills/sanity-functions/references/blueprint-config.md index b8b2cbc..1f18442 100644 --- a/skills/sanity-functions/references/blueprint-config.md +++ b/skills/sanity-functions/references/blueprint-config.md @@ -51,15 +51,19 @@ Defines a function triggered by document lifecycle events. ```typescript defineDocumentFunction({ name: 'my-function', - src: 'functions/my-function', // optional, inferred from name - memory: 1, // GB, default 1, max 10 - timeout: 10, // seconds, default 10, max 900 + displayName: 'My Function', // optional, human-readable name + src: 'functions/my-function', // optional, inferred from name + memory: 1, // GB, default 1, max 10 + timeout: 10, // seconds, default 10, max 900 + runtime: 'nodejs22.x', // 'node' | 'nodejs22.x' | 'nodejs24.x' + project: 'yourProjectId', // optional, required if blueprint is org-scoped + robotToken: 'token-name', // optional, custom robot token event: { on: ['create', 'update'], filter: '_type == "post"', projection: '{title, _id, _type, slug}', - includeDrafts: false, // default false - includeAllVersions: false, // default false + includeDrafts: false, // default false + includeAllVersions: false, // default false resource: { type: 'dataset', id: 'projectId.datasetName', @@ -76,9 +80,13 @@ defineDocumentFunction({ | Option | Type | Default | Description | | :--- | :--- | :--- | :--- | | `name` | `string` | required | Function name. Must match the directory name under `functions/`. | +| `displayName` | `string` | — | Human-readable display name for the function. | | `src` | `string` | `functions/` | Path to the function source directory. | | `memory` | `number` | `1` | Memory allocation in GB. Max 10. | | `timeout` | `number` | `10` | Execution timeout in seconds. Max 900. | +| `runtime` | `string` | `'nodejs22.x'` | Runtime environment: `'node'`, `'nodejs22.x'`, or `'nodejs24.x'`. | +| `project` | `string` | — | Project ID. Required if the blueprint is scoped to an organization. | +| `robotToken` | `string` | — | Custom robot token name for the function. | | `event` | `object` | required | Event configuration (see below). | | `env` | `Record` | — | Environment variables available via `process.env`. | @@ -86,7 +94,7 @@ defineDocumentFunction({ | Option | Type | Default | Description | | :--- | :--- | :--- | :--- | -| `on` | `string[]` | required | Event types: `'create'`, `'update'`, `'delete'`, `'publish'`. `'publish'` cannot be combined with others. | +| `on` | `string[]` | required | Event types: `'create'`, `'update'`, `'delete'`. The legacy `'publish'` event (equivalent to `['create', 'update']`) is deprecated. | | `filter` | `string` | — | GROQ filter expression (body only, no `*[...]` wrapper). | | `projection` | `string` | — | GROQ projection to shape `event.data`. Wrap in `{}`. | | `includeDrafts` | `boolean` | `false` | Whether to trigger on draft document changes. | From e511b65d9566ea657386aa65aad27342fa4455f7 Mon Sep 17 00:00:00 2001 From: Brian LeRoux Date: Wed, 11 Mar 2026 14:49:34 -0700 Subject: [PATCH 3/6] fix: incorporating feedbacks --- AGENTS.md | 2 +- skills/sanity-best-practices/SKILL.md | 2 + .../references/functions.md | 477 ++++++++++++++++++ skills/sanity-functions/SKILL.md | 12 +- 4 files changed, 490 insertions(+), 3 deletions(-) create mode 100644 skills/sanity-best-practices/references/functions.md diff --git a/AGENTS.md b/AGENTS.md index 6de1158..d197991 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ If the Sanity MCP server (`https://mcp.sanity.io`) is available, use `list_sanit | **TypeGen** | `typegen`, `typescript`, `types`, `infer`, `satisfies`, `type generation` | `skills/sanity-best-practices/references/typegen.md` | | **App SDK** | `app sdk`, `custom app`, `useDocuments`, `useDocument`, `DocumentHandle`, `SanityApp`, `sdk-react` | `skills/sanity-best-practices/references/app-sdk.md` | | **Blueprints** | `blueprints`, `IaC`, `infrastructure`, `stack`, `defineBlueprint` | `skills/sanity-best-practices/references/blueprints.md` | -| **Sanity Functions** | `functions`, `serverless`, `event handler`, `documentEventHandler`, `defineDocumentFunction`, `sanity.blueprint.ts` | `skills/sanity-functions/SKILL.md` | +| **Sanity Functions** | `functions`, `serverless`, `event handler`, `documentEventHandler`, `defineDocumentFunction`, `sanity.blueprint.ts` | `skills/sanity-best-practices/references/functions.md`, `skills/sanity-functions/SKILL.md` | ### Using the Knowledge Router diff --git a/skills/sanity-best-practices/SKILL.md b/skills/sanity-best-practices/SKILL.md index 81477cb..6d3e227 100644 --- a/skills/sanity-best-practices/SKILL.md +++ b/skills/sanity-best-practices/SKILL.md @@ -22,6 +22,7 @@ Reference these guidelines when: - Migrating content from other systems - Building custom apps with the Sanity App SDK - Managing infrastructure with Blueprints +- Writing backend code with Sanity Functions ## Quick Reference @@ -38,6 +39,7 @@ Reference these guidelines when: - `project-structure` - Monorepo and embedded Studio patterns - `app-sdk` - Custom applications with Sanity App SDK - `blueprints` - Infrastructure as Code with Sanity Blueprints +- `functions` - Backend code with Sanity Functions ### Topic Guides diff --git a/skills/sanity-best-practices/references/functions.md b/skills/sanity-best-practices/references/functions.md new file mode 100644 index 0000000..5fab09d --- /dev/null +++ b/skills/sanity-best-practices/references/functions.md @@ -0,0 +1,477 @@ +--- +title: Sanity Functions +description: Rules for Sanity Functions — serverless event handlers that react to content changes in Sanity's Content Lake. Covers blueprint configuration, handler patterns, testing, deployment, and recursion control. +--- + +# Sanity Functions + +## What are Sanity Functions? + +Sanity Functions are serverless event handlers hosted on Sanity's infrastructure. They execute custom logic when content changes occur — no infrastructure management required. Functions are configured via **Blueprints** (declarative resource definitions) and triggered by document lifecycle events. + +> **Experimental feature**: APIs are subject to change. Always use the latest Sanity CLI (`npx sanity@latest`). + +## When to use Functions + +- Enrich, validate, or constrain content on publish +- Trigger external services (CDN purge, deploy hooks, notifications) +- Automate workflows (translation, tagging, cross-posting) +- Sync content to external systems (Algolia, Elasticsearch) +- Set computed/derived fields (timestamps, slugs, summaries) +- Invoke Agent Actions (Generate, Transform, Translate) in response to content events + +## Requirements + +| Dependency | Version | +|:---|:---| +| Node.js | v24.x (matches deployed runtime) | +| Sanity CLI | v4.12.0+ | +| `@sanity/blueprints` | Latest | +| `@sanity/functions` | Latest | +| `@sanity/client` | v7.12.0+ (includes recursion protection) | + +## Project Structure + +Organize functions alongside your Sanity project, one level above the Studio directory: + +``` +my-project/ +├── studio/ +├── next-app/ +├── functions/ +│ ├── my-function/ +│ │ ├── index.ts # Handler code (entry point) +│ │ └── package.json # (optional) function-level dependencies +│ └── another-function/ +│ └── index.ts +├── sanity.blueprint.ts # Blueprint configuration +├── package.json # Project-level dependencies +└── node_modules/ +``` + +The function directory name must match the `name` in the blueprint config. Each function exports a `handler` from its `index.ts` (or `index.js`). + +--- + +## Step-by-step: Creating a Function + +### 1. Initialize a Blueprint + +```bash +npx sanity@latest blueprints init . \ + --type ts \ + --stack-name production \ + --project-id +``` + +### 2. Scaffold a Function + +```bash +npx sanity@latest blueprints add function \ + --name my-function \ + --fn-type document-publish \ + --installer npm +``` + +`--fn-type` options: `document-create`, `document-update`, `document-publish` (deprecated), `document-delete`. + +### 3. Configure the Blueprint + +```typescript +// sanity.blueprint.ts +import { defineBlueprint, defineDocumentFunction } from '@sanity/blueprints' + +export default defineBlueprint({ + resources: [ + defineDocumentFunction({ + name: 'my-function', + event: { + on: ['create', 'update'], + filter: '_type == "post"', + }, + }), + ], +}) +``` + +### 4. Write the Handler + +```typescript +// functions/my-function/index.ts +import { documentEventHandler } from '@sanity/functions' +import { createClient } from '@sanity/client' + +interface PostData { + _id: string + _type: string + title: string +} + +export const handler = documentEventHandler(async ({ context, event }) => { + const { data } = event + + const client = createClient({ + ...context.clientOptions, + apiVersion: '2025-05-08', + }) + + try { + await client.patch(data._id, { + setIfMissing: { firstPublished: new Date().toISOString() }, + }) + console.log(`Set firstPublished on ${data._id}`) + } catch (error) { + console.error('Failed to patch document:', error) + } +}) +``` + +### 5. Test Locally + +```bash +# Visual dev playground +npx sanity@latest functions dev + +# CLI testing +npx sanity@latest functions test my-function \ + --dataset production \ + --with-user-token + +# With a specific document +npx sanity@latest functions test my-function \ + --document-id abc123 \ + --dataset production \ + --with-user-token +``` + +### 6. Deploy + +```bash +npx sanity@latest blueprints deploy +``` + +### 7. View Logs + +```bash +npx sanity@latest functions logs my-function +npx sanity@latest functions logs my-function --watch +``` + +--- + +## Handler Reference + +Every handler receives `{ context, event }`: + +### `context` + +| Property | Type | Description | +|:---|:---|:---| +| `clientOptions.apiHost` | `string` | API host URL | +| `clientOptions.projectId` | `string` | Sanity project ID | +| `clientOptions.dataset` | `string` | Dataset name | +| `clientOptions.token` | `string` | Robot token (deployed only) | +| `local` | `boolean \| undefined` | `true` during local testing | +| `eventResourceType` | `string` | `'dataset'` or `'media-library'` | +| `eventResourceId` | `string` | e.g., `'projectId.datasetName'` | + +### `event` + +```typescript +{ + data: { + _id: string + _type: string + // ... rest of document (shaped by projection if set) + } +} +``` + +When testing locally, `context.clientOptions` only has `projectId` and `apiHost`. Use `--dataset` and `--with-user-token` flags to supply the rest. + +--- + +## Blueprint Configuration + +### `defineDocumentFunction` Options + +| Option | Type | Default | Description | +|:---|:---|:---|:---| +| `name` | `string` | required | Must match the directory name under `functions/` | +| `displayName` | `string` | — | Human-readable display name | +| `src` | `string` | `functions/` | Path to function source directory | +| `memory` | `number` | `1` | Memory in GB (max 10) | +| `timeout` | `number` | `10` | Timeout in seconds (max 900) | +| `runtime` | `string` | `'nodejs22.x'` | `'node'`, `'nodejs22.x'`, or `'nodejs24.x'` | +| `event` | `object` | required | Event configuration (see below) | +| `env` | `Record` | — | Environment variables via `process.env` | + +### `event` Options + +| Option | Type | Default | Description | +|:---|:---|:---|:---| +| `on` | `string[]` | required | `'create'`, `'update'`, `'delete'`. Legacy `'publish'` is deprecated. | +| `filter` | `string` | — | GROQ filter body (no `*[...]` wrapper) | +| `projection` | `string` | — | GROQ projection to shape `event.data`. Wrap in `{}`. | +| `includeDrafts` | `boolean` | `false` | Trigger on draft changes | +| `includeAllVersions` | `boolean` | `false` | Trigger on all document versions | +| `resource` | `object` | — | Scope to dataset: `{ type: 'dataset', id: 'projectId.datasetName' }` | + +### `defineMediaLibraryAssetFunction` + +For Media Library asset events. Requires `@sanity/blueprints` v0.4.0+ and `@sanity/functions` v1.1.0+. + +```typescript +import { defineBlueprint, defineMediaLibraryAssetFunction } from '@sanity/blueprints' + +export default defineBlueprint({ + resources: [ + defineMediaLibraryAssetFunction({ + name: 'asset-handler', + event: { + on: ['delete'], + filter: 'documents::incomingGlobalDocumentReferenceCount() > 0', + projection: '{_id, versions, title}', + resource: { + type: 'media-library', + id: 'mlYourLibraryId', + }, + }, + }), + ], +}) +``` + +--- + +## Event Types + +| Event | Description | +|:---|:---| +| `create` | New document created | +| `update` | Existing document modified (for published docs, fires when a draft/version is published) | +| `delete` | Document deleted | +| `publish` | **Deprecated.** Equivalent to `['create', 'update']`. Migrate to explicit events. | + +Often best to use `['create', 'update']` together for published document triggers. + +--- + +## GROQ Filter Tips + +- Only the filter body — `_type == 'post'`, not `*[_type == 'post']` +- `delta::changedAny(fieldName)` — trigger only when specific fields change +- `sanity::dataset() == 'production'` — scope to a dataset without `resource` config +- `_id in path('drafts.**')` with `includeDrafts: true` — draft-only triggers +- Combine conditions to prevent recursion: `_type == 'post' && !defined(processedAt)` + +--- + +## Projections + +- Shape the data passed to `event.data` +- Limited to the invoking document's scope (plus `→` for references) +- Nested filters in projections (like `*[references(^._id)]`) will fail silently — query inside the function instead +- Wrap in `{}`: `projection: '{title, _id, slug}'` + +--- + +## Environment Variables + +Three ways to set them: + +1. Blueprint config: `env: { MY_VAR: 'value' }` +2. CLI: `npx sanity functions env add my-function MY_VAR my-value` +3. Local testing: `MY_VAR=value npx sanity functions test my-function` + +Access in handler code via `process.env.MY_VAR`. + +--- + +## Critical Rules + +### Preventing Recursion + +If your function mutates the same document type it listens to, you **will** create an infinite loop. + +**✅ Correct — use GROQ filters to exclude processed documents:** +```typescript +defineDocumentFunction({ + name: 'first-published', + event: { + on: ['create', 'update'], + filter: "_type == 'post' && !defined(firstPublished)", + }, +}) +``` + +**✅ Correct — use `@sanity/client` v7.12.0+ for automatic lineage headers:** +```typescript +import { createClient } from '@sanity/client' + +// Client automatically sets X-Sanity-Lineage header +// Recursive chains are limited to 16 invocations +const client = createClient({ + ...context.clientOptions, + apiVersion: '2025-05-08', +}) +``` + +**❌ Incorrect — no recursion guard:** +```typescript +defineDocumentFunction({ + name: 'update-post', + event: { + on: ['create', 'update'], + filter: "_type == 'post'", // Will re-trigger on its own writes! + }, +}) +``` + +### Local Testing Safety + +Use `context.local` to prevent accidental mutations during testing: + +```typescript +// Skip mutations entirely in test +if (!context.local) { + await client.createOrReplace(someDoc) +} + +// Or use dryRun +await client.patch(event.data._id, { + set: { processed: true }, +}).commit({ dryRun: context.local }) +``` + +### Size and Performance + +- Max function size: 200MB (including dependencies) +- Prefer slim, platform-agnostic packages — no native code wrappers +- Split large logic across multiple functions +- Larger functions = slower cold starts + +### Rate Limits + +- 200 invocations per function per 30 seconds +- 4000 invocations per project per 30 seconds + +### Cost + +Cost = invocations × (memory GB × duration seconds). Default is 1GB memory. A function averaging 1GB and 40ms duration can run ~500k invocations within 20K GB-seconds. + +--- + +## Common Patterns + +### Deploy hook / CDN invalidation + +```typescript +export const handler = documentEventHandler(async ({ context, event }) => { + const URL = process.env.DEPLOY_HOOK_URL + if (!URL) throw new Error('DEPLOY_HOOK_URL is not set') + + await fetch(URL) + console.log('Deploy hook triggered') +}) +``` + +### Auto-translate with Agent Actions + +```typescript +export const handler = documentEventHandler(async ({ context, event }) => { + const client = createClient({ ...context.clientOptions, apiVersion: 'vX' }) + + await client.agent.action.translate({ + schemaId: 'your-schema-id', + async: true, + documentId: event.data._id, + languageFieldPath: 'language', + targetDocument: { + operation: 'createOrReplace', + _id: `${event.data._id}-el-GR`, + }, + fromLanguage: { id: 'en-US', title: 'English' }, + toLanguage: { id: 'el-GR', title: 'Greek' }, + }) +}) +``` + +### Auto-tag with Agent Actions + +```typescript +export const handler = documentEventHandler(async ({ context, event }) => { + const client = createClient({ ...context.clientOptions, apiVersion: 'vX' }) + + await client.agent.action.generate({ + schemaId: 'your-schema-id', + documentId: event.data._id, + instruction: 'Analyze the content and generate 3 relevant tags. Reuse existing tags when possible.', + target: { path: ['tags'] }, + async: true, + }) +}) +``` + +### Slack notification on publish + +```typescript +export const handler = documentEventHandler(async ({ context, event }) => { + const WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL + if (!WEBHOOK_URL) throw new Error('SLACK_WEBHOOK_URL not set') + + await fetch(WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `📝 New content published: *${event.data.title || event.data._id}* (${event.data._type})`, + }), + }) +}) +``` + +### Multiple functions in one blueprint + +```typescript +export default defineBlueprint({ + resources: [ + defineDocumentFunction({ + name: 'first-published', + event: { + on: ['create', 'update'], + filter: "_type == 'post' && !defined(firstPublished)", + }, + }), + defineDocumentFunction({ + name: 'notify-slack', + event: { + on: ['create', 'update'], + filter: "_type == 'post'", + projection: '{title, _id}', + }, + }), + defineDocumentFunction({ + name: 'sync-algolia', + timeout: 30, + event: { + on: ['create', 'update', 'delete'], + filter: "_type == 'product'", + }, + }), + ], +}) +``` + +--- + +## CI/CD Deployment + +Use the [Blueprints GitHub Action](https://github.com/sanity-io/blueprints-action): + +```yaml +- uses: sanity-io/blueprints-action@v1 + with: + sanity-token: ${{ secrets.SANITY_DEPLOY_TOKEN }} +``` + +Only personal auth tokens are supported for deployment (not robot tokens). diff --git a/skills/sanity-functions/SKILL.md b/skills/sanity-functions/SKILL.md index 7def69f..a516f9e 100644 --- a/skills/sanity-functions/SKILL.md +++ b/skills/sanity-functions/SKILL.md @@ -20,9 +20,17 @@ Sanity Functions are serverless event handlers hosted on Sanity's infrastructure - Set computed/derived fields (timestamps, slugs, summaries) - Invoke Agent Actions (Generate, Transform, Translate) in response to content events +## When NOT to use Functions + +- When your logic needs to run longer than 900 seconds (15 min max timeout). +- When your dependencies exceed 200MB — native code wrappers or heavy SDKs won't fit. +- When you'd be mutating the same document type you're listening to without a reliable way to break the recursion loop (GROQ filter exclusion or a sentinel field). It's technically possible with guards, but if the logic is complex, you're asking for trouble. +- When the work is purely client-side or UI-driven (form validation, conditional field visibility) — that belongs in the Studio schema config, not a serverless function. + + ## Requirements -- **Node.js v22.x** — matches the deployed runtime +- **Node.js v24.x** — matches the deployed runtime - **Sanity CLI v4.12.0+** — use `npx sanity@latest` for latest - **@sanity/blueprints** — for blueprint configuration helpers - **@sanity/functions** — for handler types and the `documentEventHandler` wrapper @@ -328,7 +336,7 @@ await client.patch(id, ops).commit({ dryRun: context.local }) ### Cost considerations -Cost = invocations × (memory GB × duration seconds). Default is 1GB memory. A function averaging 1GB and 40ms duration can run ~500k invocations within 20K GB-seconds. Monitor usage at the organization level. +Cost = invocations × (memory GB × duration seconds). Default is 1GB memory. A function averaging 1GB and 40ms duration can run ~500k invocations within 20K GB-seconds. Monitor usage at the organization level. --- From dd5a71213927d55110de6da27cd120868585966f Mon Sep 17 00:00:00 2001 From: Brian LeRoux Date: Thu, 19 Mar 2026 11:45:08 -0700 Subject: [PATCH 4/6] Update skills/sanity-best-practices/references/functions.md Co-authored-by: Stephen Fisher --- skills/sanity-best-practices/references/functions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/sanity-best-practices/references/functions.md b/skills/sanity-best-practices/references/functions.md index 5fab09d..85d4939 100644 --- a/skills/sanity-best-practices/references/functions.md +++ b/skills/sanity-best-practices/references/functions.md @@ -466,7 +466,7 @@ export default defineBlueprint({ ## CI/CD Deployment -Use the [Blueprints GitHub Action](https://github.com/sanity-io/blueprints-action): +Use the [Blueprints GitHub Action](https://github.com/sanity-io/blueprints-actions) ```yaml - uses: sanity-io/blueprints-action@v1 From 506a5d8a5e304c2722d4101fb2bfce86945b72ca Mon Sep 17 00:00:00 2001 From: Brian LeRoux Date: Thu, 19 Mar 2026 11:45:25 -0700 Subject: [PATCH 5/6] Update skills/sanity-best-practices/references/functions.md Co-authored-by: Stephen Fisher --- skills/sanity-best-practices/references/functions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/sanity-best-practices/references/functions.md b/skills/sanity-best-practices/references/functions.md index 85d4939..9196d3f 100644 --- a/skills/sanity-best-practices/references/functions.md +++ b/skills/sanity-best-practices/references/functions.md @@ -469,7 +469,7 @@ export default defineBlueprint({ Use the [Blueprints GitHub Action](https://github.com/sanity-io/blueprints-actions) ```yaml -- uses: sanity-io/blueprints-action@v1 +- uses: sanity-io/blueprints-actions/deploy@deploy-v3 with: sanity-token: ${{ secrets.SANITY_DEPLOY_TOKEN }} ``` From d5494a272872be08a786d788c5fddb2e86638353 Mon Sep 17 00:00:00 2001 From: Brian LeRoux Date: Thu, 19 Mar 2026 11:59:46 -0700 Subject: [PATCH 6/6] Apply suggestion from @brianleroux --- skills/sanity-functions/references/blueprint-config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/sanity-functions/references/blueprint-config.md b/skills/sanity-functions/references/blueprint-config.md index 1f18442..78df6a9 100644 --- a/skills/sanity-functions/references/blueprint-config.md +++ b/skills/sanity-functions/references/blueprint-config.md @@ -13,7 +13,7 @@ npx sanity@latest blueprints init . \ --project-id ``` -Creates `sanity.blueprint.ts` and `blueprint.config.ts` in your project root. +Creates `sanity.blueprint.ts` in your project root; also creates `.sanity/blueprint.config.json` which should always be added to `.gitignore`. ### Scaffold a Function