--- 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 Serverless event handlers hosted on Sanity's infrastructure, configured via **Blueprints** and triggered by document lifecycle events, Media Library events, content-availability (sync tag) events, a schedule, or a direct call from another function. > Always use `npx sanity@latest` so CLI and runtime versions stay current. ## When to use - Set computed/derived fields (timestamps, slugs, summaries) - Enrich or validate content on publish - Trigger external services (CDN purge, deploy hooks, notifications) - Automate workflows (translation, tagging, cross-posting) - Sync content to external systems - Invoke Agent Actions in response to content events - Run recurring work on a schedule (cache expiry, digests, periodic sync) - Split a pipeline into small, separately-configured steps that call each other (PubSub + `invoke`) ## When NOT to use - Logic needs >900s execution or >200MB bundle — use an external worker - High-throughput bulk operations that exceed rate limits (200/fn/30s, 4000/project/30s) - A simple POST to an external URL on publish with no document data shaping — use a webhook - Client-side or UI-driven logic (validation, conditional fields) — belongs in Studio schema config ## 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 ``` This creates `sanity.blueprint.ts` and `.sanity/blueprint.config.json` (gitignored automatically; it links your Blueprint to a Stack and is not secret). ### 2. Scaffold a Function ```bash npx sanity@latest functions add \ --name my-function \ --type document-create --type document-update \ --installer npm ``` `--type` options: `document-create`, `document-update`, `document-delete`, `media-library-asset-create`, `media-library-asset-update`, `media-library-asset-delete`, `scheduled-function`, `sync-tag-invalidate`, `pub-sub`. ### 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'], // The handler patches the same document, which emits another update // event. Guard with !defined(firstPublished) so the function stops // matching once it has run — see "Recursion control" below. filter: '_type == "post" && !defined(firstPublished)', }, }), ], }) ``` ### 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 }`. Sync tag invalidate handlers additionally receive `done`; scheduled handlers receive only `{ context }` — see `defineSyncTagInvalidateFunction` and `defineScheduledFunction` below. PubSub handlers receive whatever the calling function passed to `invoke`. ### `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) } } ``` For sync tag invalidate functions, `event.data` is `{ syncTags: string[] }` instead. For PubSub functions, `event.data` is whatever the caller passed — no schema is enforced. 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` | `'nodejs24.x'` | `'node'`, `'nodejs22.x'`, or `'nodejs24.x'` | | `project` | `string` | — | Project ID. Required if blueprint is org-scoped. | | `robotToken` | `string` | — | Custom robot token name for the function | | `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'` | | `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', }, }, }), ], }) ``` ### `defineSyncTagInvalidateFunction` Fires when updated content becomes available for querying — after a write has propagated to the query layer, not at mutation time. The event carries the **sync tags** affected by that update: the same tags the Live Content API returns alongside query results, so you can purge exactly the cached entries that went stale instead of guessing from document types. **Blueprint:** ```typescript import { defineBlueprint, defineSyncTagInvalidateFunction } from '@sanity/blueprints' export default defineBlueprint({ resources: [ defineSyncTagInvalidateFunction({ name: 'invalidate-tags', // Scope to one dataset so a shared blueprint doesn't fire against staging event: { resource: { type: 'dataset', id: 'myProjectId.production' } }, }), ], }) ``` Scaffold with `npx sanity@latest functions add --name invalidate-tags --type sync-tag-invalidate`. There is no `on`, `filter`, or `projection` — the function fires for every batch of invalidated tags on the dataset. `event.resource` is the only scoping mechanism. **Handler** — uses `syncTagInvalidateEventHandler`, which passes a third argument, `done`: ```typescript // functions/invalidate-tags/index.ts import { syncTagInvalidateEventHandler } from '@sanity/functions' export const handler = syncTagInvalidateEventHandler(async ({ context, event, done }) => { const { syncTags } = event.data if (!context.local) { await fetch(process.env.CACHE_PURGE_URL!, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags: syncTags }), }) } // Signals that invalidation finished. Clients waiting on the Live Content // API block until this resolves — skip it and they never see the update. await done(syncTags) }) ``` **Rules:** - **Always call `done`.** It is the completion signal, not a convenience. Call it on the error path too, otherwise a failed purge stalls every subscribed client. - **One sync-tag-invalidate function per dataset.** Several of them on the same dataset race each other and produce unpredictable invalidation. - **Don't write content from this handler.** A mutation makes new content queryable, which fires the function again — an immediate loop that burns through rate limits. ### `defineScheduledFunction` Runs on a clock instead of a content event — nightly cleanup, cache expiry, digest emails, periodic sync. No document triggers it, so there is no `event.data`. Scheduled functions are **organization-scoped**: they carry no project or dataset context. The Stack must be org-scoped (`blueprints init . --organization-id `, or `blueprints promote` an existing project Stack), and any dataset access needs an explicit robot token — `context.clientOptions` will not supply `projectId` or `dataset` for you. **Blueprint:** ```typescript import { defineBlueprint, defineScheduledFunction, defineRobotToken } from '@sanity/blueprints' export default defineBlueprint({ resources: [ defineRobotToken({ name: 'my-robot', label: 'My Robot', memberships: [ { resourceType: 'project', resourceId: 'abc123', roleNames: ['editor'] }, ], }), defineScheduledFunction({ name: 'expire-cache', event: { expression: '0 0 * * *' }, // midnight daily timezone: 'America/New_York', // IANA identifier; defaults to UTC robotToken: '$.resources.my-robot.token', }), ], }) ``` Scaffold with `npx sanity@latest functions add --name expire-cache --type scheduled-function --language ts`. **Schedule options:** | Form | Example | |:---|:---| | CRON expression | `event: { expression: '0 0 * * *' }` — minute, hour, day-of-month, month, day-of-week | | Explicit fields | `event: { minute: '0', hour: '0', dayOfMonth: '*', month: '*', dayOfWeek: '*' }` | Omit `timezone` and the schedule runs in UTC. Cadence limits are plan-dependent — check the Functions pricing tier before scheduling anything minutely. **Handler** — uses `scheduledEventHandler` and receives only `{ context }`: ```typescript // functions/expire-cache/index.ts import { scheduledEventHandler } from '@sanity/functions' import { createClient } from '@sanity/client' export const handler = scheduledEventHandler(async ({ context }) => { // projectId and dataset are NOT in context here — set them explicitly const client = createClient({ projectId: 'abc123', dataset: 'production', apiVersion: '2025-05-08', token: context.clientOptions?.token, // from the robotToken above }) const stale = await client.fetch( `*[_type == "cacheEntry" && expiresAt < now()]._id`, ) if (!context.local && stale.length) { await stale .reduce((tx, id) => tx.delete(id), client.transaction()) .commit() } console.log(`Expired ${stale.length} entries`) }) ``` Deploying an org-scoped Stack requires the organization admin role, the blueprint deployer role, or a token with `sanity.blueprints.deploy`. Test with `npx sanity@latest functions dev` — playground runs don't count against usage quotas. ### `definePubSubFunction` A function with no trigger of its own — it runs only when another function calls it with `invoke`. Use it to break a pipeline into separately-configured steps instead of chaining them through document mutations. Before `invoke`, the only way for one function to reach another was to write a document and let the resulting change event fire the next function. That forced every step to be modeled as a mutation, even steps that had nothing to do with the document (posting to Slack, calling an external API). A PubSub function is called directly, so the intermediate write disappears. **Blueprint** — `name` is the only required option: ```typescript import { defineBlueprint, definePubSubFunction } from '@sanity/blueprints' export default defineBlueprint({ resources: [ definePubSubFunction({ name: 'slack-post' }), ], }) ``` Scaffold with `npx sanity@latest functions add --name slack-post --type pub-sub --installer npm`. There is no `event` block — no `on`, `filter`, `projection`, or `resource`. The other `defineDocumentFunction` options (`memory`, `timeout`, `runtime`, `env`, `robotToken`) still apply, which is the point: each step gets its own resource budget and its own permissions. **Handler** — uses `pubSubEventHandler`: ```typescript // functions/slack-post/index.ts import { pubSubEventHandler } from '@sanity/functions' export const handler = pubSubEventHandler(async ({ context, event }) => { // event.data is whatever the caller passed — validate it, it is not typed // or validated by the platform the way a document event is const { text } = event.data await fetch(process.env.SLACK_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text }), }) }) ``` The callee can't tell it was invoked by another function rather than by a document event — it just receives the `context` and `event` it was handed. #### Calling it with `invoke` ```typescript // functions/on-publish/index.ts import { documentEventHandler, invoke } from '@sanity/functions' export const handler = documentEventHandler(async ({ context, event }) => { await invoke('slack-post', { context, event: { data: { text: `Published ${event.data.title}` } }, }) }) ``` `invoke(name, { context, event }, options?)` takes an optional third argument, `{ sync: boolean }`, defaulting to `false`. | | Async (default, `sync: false`) | Sync (`sync: true`) | |:---|:---|:---| | Waits for completion? | No — only for acceptance | Yes | | Returns the callee's response? | No | Yes | | Best for | Fan-out, chaining steps, privilege separation | Steps that genuinely can't proceed without the callee's result | | Use liberally? | Yes — this is the default pattern | No — reserve for what async can't do | **Async resolving means "accepted", not "done".** `invoke` throws if the request itself is rejected (bad function name, malformed payload), but a resolved promise says only that the invocation was queued. **❌ Incorrect — treating an async `invoke` as if it returned the callee's output:** ```typescript const result = await invoke('slack-post', { context, event }) if (result.ok) { /* never runs as expected — result is not the callee's return value */ } // Same mistake, sequenced: this read happens right after the invocation is // accepted, not after resize-image has resized anything. await invoke('resize-image', { context, event }) const resized = await client.fetch(`*[_id == $id][0].resizedUrl`, { id: event.data._id }) ``` **✅ Correct — fire-and-forget, catching only acceptance errors:** ```typescript try { await invoke('slack-post', { context, event: { data: event.data } }) } catch (err) { // Only failures to *accept* the invocation land here console.error('Failed to trigger slack-post:', err) } ``` **✅ Correct — `sync: true` for a real dependency:** ```typescript const response = await invoke( 'validate-content', { context, event: { data: event.data } }, { sync: true }, ) if (!response.valid) return { skipped: true, reason: response.reason } await invoke('publish-content', { context, event: { data: event.data } }) ``` **Rules:** - **Default to async.** `sync: true` ties up the caller's timeout and memory budget for as long as the callee runs, and serializes work that should be parallel. Reaching for it on most calls usually means the logic belongs in one function, not two. - **Never `sync: true` in a fan-out loop.** `await invoke(..., { sync: true })` inside a `for` loop runs batches one at a time and blocks the caller until the last one finishes — the opposite of what fan-out is for. - **Validate `event.data` in the callee.** Nothing between the two functions checks its shape. - **Recursion limits still apply.** A chain of invocations counts toward the same rate limits as event-triggered runs; two PubSub functions invoking each other loop just as fast as a self-triggering document function. --- ## 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 | 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@latest 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 }) // Or use noWrite for Agent Actions await client.agent.action.generate({ schemaId: 'your-schema-id', documentId: event.data._id, instruction: 'Summarize this document', target: { path: ['summary'] }, noWrite: context.local, }) ``` ### Limits - Max bundle size: 200MB (including dependencies). Prefer slim, platform-agnostic packages. - Rate limits: 200 invocations/fn/30s, 4000/project/30s - Max timeout: 900s. Larger functions = slower cold starts. ### 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. [Monitor usage at the organization level](https://www.sanity.io/manage). --- ## Common Patterns ### Deploy hook / CDN invalidation **Blueprint:** ```typescript defineDocumentFunction({ name: 'deploy-hook', event: { on: ['create', 'update'], filter: '_type == "page"', }, }) ``` **Handler:** ```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') }) ``` Set the env var: `npx sanity@latest functions env add deploy-hook DEPLOY_HOOK_URL https://...` ### Set a timestamp on first publish Uses the same pattern as the step-by-step example above. The key insight: the `!defined(firstPublished)` GROQ filter prevents re-triggering after the field is set. The `setIfMissing` patch is a redundant safety net. ```typescript defineDocumentFunction({ name: 'first-published', event: { on: ['create', 'update'], filter: '_type == "post" && !defined(firstPublished)', }, }) ``` ### Auto-translate with Agent Actions **Blueprint:** ```typescript defineDocumentFunction({ name: 'translate', event: { on: ['create', 'update'], filter: "_type == 'post' && language == 'en-US'", projection: '{_id}', }, }) ``` **Handler:** ```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: 'create', }, fromLanguage: { id: 'en-US', title: 'English' }, toLanguage: { id: 'el-GR', title: 'Greek' }, }) }) ``` The GROQ filter ensures only English documents trigger the function. The translated document gets a different `language` value, preventing recursive triggers. Let Sanity assign the translated document's `_id` for ordinary localized content. To find or update translations later, query by language, slug, or translation metadata instead of deriving IDs from the source document. Reserve explicit `targetDocument._id` values for singleton-style targets. ### Auto-tag with Agent Actions **Blueprint:** ```typescript defineDocumentFunction({ name: 'auto-tag', event: { on: ['create', 'update'], // Only fire while tags are missing. The handler writes to `tags`, which // emits another `update` event — without this guard the function would // re-trigger itself in a loop. Once tags exist, the filter stops matching. filter: "_type == 'post' && !defined(tags)", projection: '{_id, title, body}', }, }) ``` **Handler:** ```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})`, }), }) }) ``` ### Fan out to several PubSub functions on publish One document event, many independent side effects — each in its own function with its own timeout, memory, and permissions. **Blueprint:** ```typescript export default defineBlueprint({ resources: [ defineDocumentFunction({ name: 'on-publish', event: { on: ['create', 'update'], filter: "_type == 'post'" }, }), definePubSubFunction({ name: 'post-to-bluesky' }), definePubSubFunction({ name: 'post-to-linkedin' }), definePubSubFunction({ name: 'post-to-mastodon' }), ], }) ``` **Handler:** ```typescript import { documentEventHandler, invoke } from '@sanity/functions' export const handler = documentEventHandler(async ({ context, event }) => { await Promise.all([ invoke('post-to-bluesky', { context, event }), invoke('post-to-linkedin', { context, event }), invoke('post-to-mastodon', { context, event }), ]) }) ``` `Promise.all` here resolves once every invocation is *accepted* — not once every post is live. If one social API is slow, that slowness stays inside its own function instead of eating this handler's timeout. ### Scope to a specific dataset **Option A — `resource` config:** ```typescript defineDocumentFunction({ name: 'production-only', event: { on: ['update'], filter: "_type == 'post'", resource: { type: 'dataset', id: 'myProjectId.production' }, }, }) ``` **Option B — GROQ filter:** ```typescript defineDocumentFunction({ name: 'production-only', event: { on: ['update'], filter: "_type == 'post' && sanity::dataset() == 'production'", }, }) ``` ### 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:** ```typescript 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) }) ``` ### Recursion control with custom HTTP clients If not using `@sanity/client`, implement lineage tracking manually: ```typescript 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/${context.clientOptions.dataset}`, { 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 } } }], }), }) }) ``` ### 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-actions) ```yaml - uses: sanity-io/blueprints-actions/deploy@deploy-v3 with: sanity-token: ${{ secrets.SANITY_DEPLOY_TOKEN }} ``` Mint a long-lived deploy token with `npx sanity@latest blueprints mint-deploy-token` (creates a robot token with the role required to plan, deploy, and destroy) and store it as a CI secret. Recommended workflow: `blueprints plan` on pull requests, `blueprints deploy` on merge to main. See the `blueprints` reference for CI environment variables and exit codes.