Function to function invocation
Learn how to invoke one function from another.
Sanity Functions can now invoke other Sanity Functions directly from within your code. Combined with Runtime resource discovery, this lets you chain, fan out, and compose function logic without routing everything back through document change events.
Previously, if one function's work needed to trigger another function, the only way was through document change events: a function would modify a document, that mutation would raise a new change event, and Sanity would invoke the next function in response. This worked, but it meant every step in a chain had to be modeled as a document mutation, even when no document change was actually the point.
Sanity Functions now support invoking a function directly from your code, and exposes the other resources in your Blueprint (functions, CORS origins, datasets, etc.) so you can reference them by name at runtime.
Prerequisites:
- Complete the Functions quick start, or be comfortable creating and deploying a function.
- Use the latest version of the
sanityCLI (sanity@latest) to interact with Blueprints and Functions as shown in this guide. You can always run the latest CLI commands withnpx sanity@latest.
Chaining function invocations
Without invoke: chaining through document events
- Modifying a document raises a document change event.
- Sanity invokes your code.
- Your code modifies the document.
- Another document change event is raised.
- Sanity invokes the next function.
- Repeat for each step in the chain.
Every link in the chain depended on a document mutation to trigger the next one, even for steps that had nothing to do with the document itself, such as posting a Slack message or calling an external API.
With invoke: chaining through invoke
- Modifying a document raises a document change event.
- Sanity invokes your code.
- Your code invokes a Sanity PubSub Function directly.
The intermediate document mutation is no longer required. A function can call the next step in the pipeline as a normal function call.
Why use invoke?
Function composition. Break logic into small, focused functions and call them from one another, instead of duplicating logic across functions.
Fan-out processing. A single function can invoke many others in parallel, distributing heavy workloads across multiple invocations rather than processing everything serially in one function.
Privilege separation. A broadly-permissioned function can hand off sensitive operations to a narrowly-scoped function, rather than holding every permission itself.
Resource isolation. Different workloads can run with their own memory and timeout configurations, invoked dynamically from a coordinating function instead of being forced to share one configuration.
How to invoke a function
Step 1: Create a PubSub function
Create a pubsub function, a Sanity Function you can trigger from other functions.
import { pubSubEventHandler } from '@sanity/functions'
export const handler = pubSubEventHandler(
async ({ context, event }) => {
const time = new Date().toLocaleTimeString()
console.log(`Your pubsub Sanity Function was called at ${time}`)
}
)Step 2: Add it to your Blueprint
Add this new function to your Blueprint.
import { defineBlueprint, definePubSubFunction }
from '@sanity/blueprints'
export default defineBlueprint({
resources: [
definePubSubFunction({ name: 'slack-post' })
],
})Step 3: Import invoke
Import invoke from the standard functions library in any function that you want to invoke other functions.
import { invoke } from '@sanity/functions'Step 4: Call invoke
Call invoke with the name of the target function, passing along your context and the event payload you want it to receive.
await invoke('slack-post', {
context,
event: { data: message }
})The target function (slack-post in this example) runs as its own function invocation, receiving whatever context and event you pass it - it doesn't need to know it was invoked by another function rather than by a document event.
Understanding invoke's execution modes
invoke takes an optional third parameter, { sync: boolean }. If you omit it, sync defaults to false. This is the existing async behavior, unchanged. If you pass { sync: true }, invoke waits for the target function to finish and gives you back its response.
Async (default): fire-and-forget
invoke resolves as soon as the target function's invocation request is accepted. It does not wait for that function to finish running. If the request itself is rejected (bad name, bad payload, etc.), invoke throws. A resolved async invoke call tells you "the function was triggered," not "the function is done."
❌ Wrong: assuming async invoke waits for a result
import { invoke } from '@sanity/functions'
export default async function handler(context, event) {
// WRONG - async invoke does not return the invoked function's
// output.
// This will be undefined even though 'slack-post' ran successfully.
const result = await invoke('slack-post', {
context,
event: { data: event.data }
})
if (result.ok) {
// Never behaves as expected - `result` isn't the invoked
// function's return value in async mode.
console.log('Message posted:', result.ok)
}
}Or…
// WRONG - chaining logic that depends on the invoked function
// having already completed its work.
await invoke('resize-image', { context, event })
// Runs immediately after invoke() resolves, not after
// resize-image actually finishes resizing anything.
const resized = await client.fetch
(`*[_id == $id][0].resizedUrl`, { id: event.data._id })✅ Correct: treat async invoke as fire-and-forget
import { invoke } from '@sanity/functions'
export default async function handler(context, event) {
try {
// sync defaults to false - invoke resolves once the call
// is accepted, that's all we can rely on here.
await invoke('slack-post', {
context,
event: { data: event.data }
})
} catch (err) {
// Only errors *accepting* the invocation land here
// (bad function name, malformed payload, etc.)
console.error('Failed to trigger slack-post:', err)
}
}Or…
// ✅ Correct: fan-out, where each invoked function is
// independent and doesn't need to report back synchronously.
await Promise.all(
batches.map((batch) =>
invoke('process-batch', { context, event: { data: batch } })
)
)
// All we know here: every batch was accepted for processing,
// not that processing is complete.Sync: waiting for a response
Passing { sync: true } as the third argument tells invoke to wait for the target function to finish executing, and to return its response.
const response = await invoke(
'slack-post',
{ context, event: { data: event.data } },
{ sync: true }
)Use this only when your function's next step genuinely depends on the invoked function's output or on it having definitely finished. For example, a validation function that needs a yes/no answer before deciding whether to proceed. Sync invocation should be the exception, not the default. It ties up your caller's execution (and its timeout/memory budget) for as long as the callee takes to run, and it forces steps to execute one at a time instead of in parallel. This is the opposite of what fan-out and privilege separation are meant to achieve. If you find yourself reaching for { sync: true } in most of your invoke calls, that's usually a sign the logic belongs in one function rather than two.
❌ Wrong: sync invocation as the default habit
// WRONG - no actual dependency on the result; this should
// just be a normal async invoke.
const response = await invoke(
'slack-post',
{ context, event: { data: event.data } },
{ sync: true }
)
// response is never used - we paid for a synchronous wait
// for nothing.Or…
// WRONG - using sync to fan out, defeating the purpose of
// parallel, independent invocations.
for (const batch of batches) {
await invoke('process-batch', {
context,
event: { data: batch }
},
{ sync: true }
)
}
// This serializes what should be parallel work, and blocks
// this function until every batch finishes one at a time.✅ Correct: sync invocation for a genuine dependency
export default async function handler(context, event) {
// We can't proceed without knowing whether this content
// passes validation - that can only be answered synchronously.
const response = await invoke(
'validate-content',
{ context, event: { data: event.data } },
{ sync: true }
)
if (!response.valid) {
return { skipped: true, reason: response.reason }
}
// Only now, with a real answer in hand, do we continue.
await invoke('publish-content', {
context,
event: { data: event.data } }
)
}Deciding which mode to use
Async (default / | Sync ( | |
|---|---|---|
Waits for completion? | No - only for acceptance | Yes |
Returns 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 cases async can't solve |
Example: fan out
In this example our function receives a document mutation event when a blog post goes from draft to published. The act of publishing means we want to blast out this new blog post to all of our socials.
At a high level, the pipeline looks like:
- Our document change function is invoked when the post becomes published.
- The function invokes many functions in parallel to post to social media sites.
import { documentEventHandler, invoke } from '@sanity/functions'
export const handler = documentEventHandler(async ({ context, event }) => {
const time = new Date().toLocaleTimeString()
console.log(`👋 A new blog post has been published at ${time}`)
await Promise.all([
invoke('post-to-bluesky', { context, event }),
invoke('post-to-linkedin', { context, event }),
invoke('post-to-mastodon', { context, event }),
invoke('post-to-threads', { context, event }),
invoke('post-to-x', { context, event }),
])
})This mirrors the fan-out pattern above: the document change logic and the social-posting logic stay in separate, independently-configured functions, connected by invoke instead of a document mutation.