# Course: Ship an agent or agentic workflow on Sanity
https://www.sanity.io/learn/course/agents-and-agentic-workflows

Build an AI agent that answers questions from your Sanity content, or an automated content pipeline that drafts copy and waits for a person to approve it. Leave with a working build and a clear picture of what an agent can find, what it is allowed to do, and where a human belongs in the loop.

---

## Navigation

## Contents

1. [Your mission, should you choose to accept it](https://www.sanity.io/learn/course/agents-and-agentic-workflows/introduction-ship-an-agent) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/introduction-ship-an-agent.md)
2. [Before you start](https://www.sanity.io/learn/course/agents-and-agentic-workflows/before-you-start-ship-an-agent) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/before-you-start-ship-an-agent.md)
3. [Track 1: Meet Green & Gather](https://www.sanity.io/learn/course/agents-and-agentic-workflows/meet-green-and-gather) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/meet-green-and-gather.md)
4. [Mission 1-1: Point an agent at your content](https://www.sanity.io/learn/course/agents-and-agentic-workflows/point-an-agent-at-your-content) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/point-an-agent-at-your-content.md)
5. [Start your knowledge base build](https://www.sanity.io/learn/course/agents-and-agentic-workflows/start-your-knowledge-base-build) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/start-your-knowledge-base-build.md)
6. [Mission 1-2: See every way it can search](https://www.sanity.io/learn/course/agents-and-agentic-workflows/see-every-way-it-can-search) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/see-every-way-it-can-search.md)
7. [Mission 1-3: Add a knowledge base as a second source](https://www.sanity.io/learn/course/agents-and-agentic-workflows/add-a-knowledge-base-as-a-second-source) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/add-a-knowledge-base-as-a-second-source.md)
8. [Mission 1-4: Teach it which source to use](https://www.sanity.io/learn/course/agents-and-agentic-workflows/teach-it-which-source-to-use) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/teach-it-which-source-to-use.md)
9. [Mission 1-5: Personalize it](https://www.sanity.io/learn/course/agents-and-agentic-workflows/personalize-it) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/personalize-it.md)
10. [Mission 1-6: Draw the boundary](https://www.sanity.io/learn/course/agents-and-agentic-workflows/draw-the-boundary) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/draw-the-boundary.md)
11. [Track 2: What runs itself](https://www.sanity.io/learn/course/agents-and-agentic-workflows/what-runs-itself) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/what-runs-itself.md)
12. [Mission 2-1: Run code when content changes](https://www.sanity.io/learn/course/agents-and-agentic-workflows/run-code-when-content-changes) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/run-code-when-content-changes.md)
13. [Mission 2-2: Add an AI step](https://www.sanity.io/learn/course/agents-and-agentic-workflows/add-an-ai-step) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/add-an-ai-step.md)
14. [Mission 2-3: Chain the steps together](https://www.sanity.io/learn/course/agents-and-agentic-workflows/chain-the-steps-together) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/chain-the-steps-together.md)
15. [Mission 2-4: Put a human in front of it](https://www.sanity.io/learn/course/agents-and-agentic-workflows/put-a-human-in-front-of-it) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/put-a-human-in-front-of-it.md)
16. [What you built (mission accomplished!)](https://www.sanity.io/learn/course/agents-and-agentic-workflows/what-you-built) · [markdown](https://www.sanity.io/learn/course/agents-and-agentic-workflows/what-you-built.md)

---

## Lesson 1: Your mission, should you choose to accept it
https://www.sanity.io/learn/course/agents-and-agentic-workflows/introduction-ship-an-agent

Build an AI agent grounded in your Sanity content, or a content pipeline that drafts copy and holds it for human approval. Both start from the same problem: an LLM put in front of real content, and the question of what it can find and what it is allowed to do.

Build an AI agent or an agentic workflow on Sanity in an afternoon, with an agent kit of prompts, skills, and instructions written for the coding agent in your editor.



**This is the documentation for the Sanity Pioneers workshop.** It includes two tracks: 



1. Track 1 builds an agent that answers questions from your content. 

2. Track 2 builds a content pipeline that runs when a document changes and holds its output for approval. 


Both run on the same repo and the same dataset. Be sure to join the [workshop Discord](https://discord.gg/sanity-1304483263171264613) to see what others are building and share your questions.



### What you're building, and why



Green & Gather is a fictional fast-casual chain with 40 locations across three markets, a seasonal menu, and allergen facts scattered across menu data, linked recipes, supplier spec sheets, and policy documents. Its dataset is the one you build on, and its problems are the ones that show up the moment a team puts an LLM near real content.



The first is retrieval. An agent bolted onto a dataset gives a wrong answer, and you cannot tell whether the model or the retrieval is at fault. A guest asks whether the Thai Crunch Bowl is peanut-free. The menu, the recipe, and the FAQ all say yes. The peanut exposure is in a supplier spec sheet, a PDF that will never live in a CMS, so an agent pointed at the menu answers confidently and wrongly. Once that agent is in front of customers, it can also see unlaunched items and internal notes, with a line in the prompt as the only thing keeping them out of an answer.



The second is content creation. Every menu item needs a guest-facing description, three market variants, and an allergen callout, and someone writes all of it by hand, once per item and once per market. Four items in the seed describe fewer allergens than their recipes contain. A Function with an AI step could draft that copy in seconds, but only if a person approves it before it publishes and can see exactly what the AI changed.



In this workshop, you'll build both. An agent that answers from the content and is limited, server-side, to what it is allowed to reach. You’ll also build a pipeline that drafts the copy from the recipe, stops when the recipe and menu disagree, and waits for a person before anything ships. Together they are content operations for Green & Gather: the agent reads the dataset, the pipeline writes to it.



### What you'll build



By the end of this course, you'll have:



- **A chat agent wired to your own Sanity dataset** through a Context MCP, with a knowledge base as a second source, routing that picks between them, and a server-side boundary on what it can reach

- **A Sanity Function** that drafts a menu item's description and allergen callout from its linked recipe, adapts the copy for three markets, and refuses to draft when the recipe contradicts the menu

- **A deployed review workflow** that holds that draft until a person clicks Approve


### Two tracks, and you can take both



**Track 1 builds an agent that answers from your content.** Sanity Context, GROQ, knowledge bases, embeddings, tool routing, and groqFilter. Nothing on this track changes a document. Every change is what the agent can reach or how it decides what to reach for.



**Track 2 builds a content process that runs itself.** Sanity Functions, Blueprints, Agent Actions, and the Workflows engine. This track does change documents, and you watch it happen in the Studio and the Function log.



They share a repo and a dataset and touch none of each other's work. Take one, the other, or both.



### How each mission works



The starter ships with an agent kit: ten mission briefs with paste-ready prompts, four `sanity-workshop-*` skills covering Context and GROQ, knowledge bases, Functions and Agent Actions, and the Workflows engine, the three public Sanity Context skills, an `AGENTS.md` with rules of engagement for your coding agent, and a `checkpoints/` folder describing what a correct result looks like for each mission. Claude Code loads the skills automatically. Other agents read `AGENTS.md` and the relevant `SKILL.md`.



Each mission introduces one topic or feature, then hands you a prompt that puts it to work on Green & Gather. You paste the prompt into your coding agent in the repo, and a constraint inside the prompt makes you stop and look at what happened: the GROQ it wrote, the field that changed, the log line.



Next, you'll get your machine and your Sanity organization set up, and confirm the agent stub replies before you build anything on top of it.



---

## Lesson 2: Before you start
https://www.sanity.io/learn/course/agents-and-agentic-workflows/before-you-start-ship-an-agent

Get your local development environment and Sanity organization configured, create the starter project, and confirm the app runs before the first mission.

The [starter repo](https://github.com/sanity-labs/sanity-agent-workshop) is a `pnpm` workspace with three pieces: 



1. A Sanity Studio with a seeded dataset for Green & Gather

2. A Next.js app with a menu page and a chat panel

3. A Sanity Function that runs when a menu item changes. 


One command creates the project from a template, and a second seeds and configures it. Before you run either, you need a few local tools. The dependencies are below, and each one has a section further down with the details.



### Local development dependencies



- [Node.js 22 LTS (or 20.19+, or 24)](https://nodejs.org/en)

- [pnpm 10 or later](https://pnpm.io/)

- [Git](https://git-scm.com/)

- A free [Sanity](https://www.sanity.io/login) account, logged in on the CLI (see below)

- Context enabled for your Sanity organization (see below)

- An organization API token with Context Viewer permissions, and your organization ID (see below)

- [ ] Configure your local development environment with Node.js, pnpm, and Git. Set up your free Sanity account, enable context, and grab your organization API token.


### Nice to have



- Your favorite flavor of AI coding (Claude Code, Cursor, Codex, etc.) with the [Sanity MCP](https://www.sanity.io/docs/ai/mcp-server) installed

- An Anthropic API key or similar (don't buy one, we'll have keys in the room for you!)

- [ ] Configure your favorite IDE with the Sanity MCP


### A Sanity account, logged in on the CLI



Sign up for a free Sanity account at `sanity.io/login`, then log in to the CLI. The starter's setup script uses your session.



```sh:Terminal
npx sanity@latest login
npx sanity@latest projects list
```

If that second command prints a list, even an empty one, without an auth error, you are set. You do not need to create a project. The starter creates one for you.



### Enable Context for your organization



Sanity Context is enabled per organization, in the Context app. Go to [sanity.io/manage](https://www.sanity.io/manage) and select your organization in the sidebar. Open `Apps` and enable `Context`.



![Image](https://cdn.sanity.io/images/3do82whm/next/d4886cfac43503023831b11777faa2b6d5470035-2506x720.png)

### Create an organization API token



In [https://www.sanity.io/manage](https://www.sanity.io/manage) with your organization selected, open `API` > `Tokens` > `Add API Token`. Create a new token with the `Context Viewer` permission.



![Image](https://cdn.sanity.io/images/3do82whm/next/97966ab19662a465af82f50fdf5b6c7d385c2cc3-1206x1868.png)

You see the token once, so save it now. It carries organization-level permissions, so keep it out of anything that runs in a browser. The starter only ever reads it server-side.



### Create the project and run bootstrap



```sh:Terminal
pnpm create sanity@latest --template sanity-labs/sanity-agent-workshop
```

The CLI asks for a project name, creates a Sanity project and dataset, puts the repo in a folder named after the project, writes the env files, adds the CORS origin, installs dependencies, and makes a first commit. It prints the folder path when it finishes. Then one more step before `pnpm dev`:



```sh:Terminal
cd <the folder the CLI printed>
pnpm bootstrap
pnpm dev
```

Bootstrap prompts for your Anthropic key, your organization ID, and your organization token, then seeds the dataset, deploys the schema, makes the dataset private, enables Dataset Embeddings, and deploys the Track 2 blueprint. Every step is caught individually, so a partial run still leaves a working repo, and the summary prints a manual command for anything that failed.



Three of those steps come back later in the course. The dataset is made private, because Mission 6's boundary is only honest on a private dataset. Dataset Embeddings are enabled and waited on, because Mission 2's semantic question needs them finished. And the blueprint is deployed with a stubbed Function behind it, which Mission 7 makes visible.



- [ ] Clone a template of the starter repo and run the bootstrap commands


### How to know it worked



Open `localhost:3000/chat` and send any message. **The agent replies "I'm not connected yet."** That is the expected reply. The app is running against your project, and connecting the agent to your content is Mission 1.



### Keep three tabs open



- **`localhost:3333`** is the Studio, where your content lives

- **`localhost:3000` **is the app, where Track 1 runs

- **`localhost:8080`** is the Functions emulator. Track 2 uses it; leave it running

> [!WARNING]
> **Early access.** Knowledge bases and the Workflows engine are early access features, and they may change while they are in beta. Where a lesson depends on behavior that could change, it says so and links the current documentation.



Next, you'll point an agent at your content and read the first query it writes.



---

## Lesson 3: Track 1: Meet Green & Gather
https://www.sanity.io/learn/course/agents-and-agentic-workflows/meet-green-and-gather

See where allergen facts actually live in this business, and what the six missions in Track 1 build on top of them.

Green & Gather is a fast-casual bowls and wraps chain: 40 locations across three markets, with NYC corporate-owned and Austin and Chicago franchised. Seasonal menu, per-location availability, monthly limited-time offers.



Its allergen facts are scattered, and that is what makes it a useful place to build. Some live in structured fields on a menu item. Some exist only if you walk from the item to its recipe and on to that recipe's ingredients. Some are prose in a policy document. And three of the sources you will end up needing were never Sanity documents at all: a supplier spec sheet, a franchise operations manual, and a saved copy of a legacy nutrition site.



Getting one of those wrong is an incident rather than a typo. 



### What you'll build



A menu concierge: a chat agent that answers allergen and dietary questions from the menu, the linked recipes, and a knowledge base you build yourself from your dataset plus four uploaded files.



The starter gives you a working chat panel wired to a route that returns a fixed string. Everything on this track happens in that route and in the Sanity Context app.



### Content is never the lever



Every change you make on this track is either what the agent can reach or how it decides what to reach for. You will not add a `spicy` field, you will not roll allergens up onto the menu item, and you will not write a guest profile into Sanity. The gaps in the data are what make the questions worth asking, and closing them would delete the lesson.



### The six missions



- **Point an agent at your content.** A Context MCP in GROQ mode, and the habit of reading the query your agent writes.

- **See every way it can search.** Exact filters, keyword ranking, and semantic ranking, plus the Instructions field that picks between them.

- **Add a knowledge base as a second source.** A build-time index that reconciles sources your dataset does not contain.

- **Teach it which source to use.** Tool descriptions and a routing table, because there is no built-in router.

- **Personalize it.** A signed-in guest's profile, injected server-side, changing what gets checked.

- **Draw the boundary.** `groqFilter`, and the difference between retrieval and governance.


### What you’ll learn



**Retrieval is a choice, and you are the one making it.** By the end of this track you will have watched one endpoint answer three questions three different ways, watched a supplier PDF correct an answer your dataset got wrong, and watched an unlaunched menu item become genuinely unreachable rather than politely withheld.



The pair that teaches it best sits one word apart. "Anything with no sesame?" is a filter over data you have declared. "Is it safe for my sesame allergy?" is a statement about what your brand is willing to guarantee. Two different doors, and nothing in the tooling picks between them for you.



Next, you'll create a Context MCP over your own dataset and read the GROQ your agent writes to answer its first question.



---

## Lesson 4: Mission 1-1: Point an agent at your content
https://www.sanity.io/learn/course/agents-and-agentic-workflows/point-an-agent-at-your-content

Connect an AI agent to a Sanity dataset through a Context MCP in GROQ mode, then read the query it generates to answer a question.

Connect an AI agent to a Sanity dataset through a Context MCP in GROQ mode, then read the query it generates to answer a question.



## Set up



### Create an MCP endpoint in the Context app



The agent reaches your dataset through an MCP endpoint you create in the Context app. Go to [sanity.io/manage](https://www.sanity.io/manage), select your organization, open **`Context` >** **`MCP Endpoints` > `New`**. 



Add your dataset as the source: choose **Dataset**, then your project, then `production`. A dataset source is what puts the endpoint in GROQ mode.



Pick the name carefully. It becomes part of the endpoint URL and cannot be changed after you save. Leave **Instructions** empty; Mission 2 fills it in. After you save, the app shows the endpoint URL, shaped like `https://api.sanity.io/v1/context/organizations/<orgId>/mcp/<name>`. Copy it into `app/.env.local` as `SANITY_CONTEXT_MCP_URL`.



- [ ] MCP endpoint created with your dataset as its source, and its URL in `app/.env.local` as `SANITY_CONTEXT_MCP_URL`


Two things to note, both already handled during setup:



> [!NOTE]
> **Auth is an organization API token** with Context Viewer permissions, sent as Authorization: Bearer. `SANITY_ORGANIZATION_TOKEN` and `SANITY_ORGANIZATION_ID` are already in `app/.env.loca`l from bootstrap. A project token is refused.


> [!NOTE]
> **GROQ mode reads a deployed schema.** Bootstrap ran `sanity schema deploy` for you.



## The lesson



[Sanity Context](https://www.sanity.io/context) is a hosted MCP server over your content. It is an endpoint that speaks the Model Context Protocol and serves a set of tools. Your agent connects to it, asks what tools it has, and calls them when it needs to know something. Your model supplies the reasoning. Context supplies the reach.



The mode comes from the source you attach. A dataset source gives you GROQ mode, which serves four tools:



- `initial_context` returns a schema overview, so the agent knows what document types exist

- `schema_explorer` drills into one type's fields

- `groq_query` runs a GROQ query and returns the result, plus meta.executedQuery and resultCount

- `array_field_reader` pages through a long array without filling the context window


Exact filters, keyword matching, and semantic ranking are all GROQ written inside `groq_query`. There is one query tool, and the retrieval strategy lives in the text of the query the agent composes. Every retrieval decision the agent makes shows up as GROQ you can read.



`ChatPanel.tsx` renders tool calls as a visible trace next to the answer, so the GROQ shows up in the chat without you instrumenting anything. The trace is verbose to allow you to have a look behind the scenes during Track 1.



**Context is the door. GROQ is what comes through it.** Read the query the agent writes. Every mission after this one changes what the agent can reach or how it decides what to reach for. None of them change the content.



### The shape of the content



Green & Gather runs 40 `location`s across three markets: NYC is corporate-owned, Austin and Chicago are franchised. Your seed holds six `location` documents standing in for them, three in NYC, two in Austin, and one in Chicago.



A `menuItem` carries `dietaryFlags`, `category`, `priceCents`, an `allergens` array, prose in body, and a `locations` array of references. Each location carries a `market`. A `recipe` links to `ingredient` documents, and each `ingredient` carries `allergenTags` and a supplier reference. menuItem.allergens is not a rollup of the recipe's ingredients.



## Your mission



You are giving the agent the ability to answer questions about the menu from live content, instead of returning a fixed reply.



That happens in `app/app/api/agent/route.ts`, and there are three pieces:



1. **Open an MCP client** against `SANITY_CONTEXT_MCP_URL`, with your organization token as the bearer.

2. **Hand that client's tool list to the model.** You are not writing tool definitions. You ask the endpoint what it can do and pass the answer through to `streamText`.

3. **Return a UI message stream.** `ChatPanel.tsx` already expects that format, which is why the stub streams its fixed reply instead of returning JSON. Leave `ChatPanel.tsx` alone.


Then you ask one question: **"What vegan bowls are under $12 in Austin?"**



Answering it correctly takes four predicates, and one of them is a dereference:



```groq
_type == "menuItem"
&& "vegan" in dietaryFlags
&& category == "bowl"
&& priceCents < 1200
&& "austin" in locations[]->market
```

`priceCents` is an integer in cents. If the agent writes < 12, it guessed the unit instead of reading the schema description. The location predicate can also be a `references()` call against the Austin location IDs.



The seed has four vegan bowls. Two are under $12. One of those two is absent from both Austin locations. So the location predicate does real work, and dropping it returns two items instead of one. That failure reads as success until someone in Austin orders a bowl you do not stock there.



Here is the split between you and your agent. It stays roughly the same for the rest of Track 1. Your agent writes the route from the skill's pattern, sends the question to `localhost:3000/api/agent`, and reports the GROQ and the items verbatim. You do the dashboard clicks and the `.env.local` paste, read the GROQ in the trace, and decide whether the query did what you would have written.



## Starting point



Look at these three things before you run the prompt, so you have something to compare against.



- **`app/app/api/agent/route.ts`** is 37 lines and streams a hardcoded string named STUB_REPLY. Read the comment block at the top; it names the three moves you are about to make. No MCP client, no model call, no tools.

- **`localhost:3000`**. Send any message in the chat panel. Every message gets the same answer, `"I'm not connected yet."` The UI works end to end with nothing behind it.

- **`localhost:3333`**. Open a `menuItem` and the `recipe` it links to. Compare `menuItem.allergens` against the `allergenTags` on the recipe's ingredients. They do not match.


## The prompt



Copy this and paste it to your coding agent, or point your agent at `missions/1-1-point-an-agent-at-your-content.md` and tell it to start.



```text:Prompt
I'm on Mission 1-1 of the Sanity agent workshop (missions/1-1-point-an-agent-at-your-content.md).
Load the sanity-workshop-context-groq skill and read AGENTS.md before you start.

I have created a Sanity Context MCP in the Context app with my project + dataset as its source
(GROQ mode) and put its URL in app/.env.local as SANITY_CONTEXT_MCP_URL. My organization ID and
organization token are already in app/.env.local.

Wire app/app/api/agent/route.ts to that endpoint with @ai-sdk/mcp and the Vercel AI SDK, with
SANITY_ORGANIZATION_TOKEN as the bearer. Keep the response a UI message stream, which is what
app/components/ChatPanel.tsx already expects — do not change ChatPanel.tsx. Write the route
yourself from the pattern in the skill; do not copy a reference implementation from another
project. A short system prompt is fine: the agent is Green & Gather's menu concierge, answers
only from retrieved content, and never assures anyone an item is safe for an allergy.

Then, with pnpm dev running, send this question to the running app's /api/agent route and read
the streamed response: "What vegan bowls are under $12 in Austin?"
Run this against the app at localhost:3000 — not through your own Sanity tools.

Show me the exact GROQ the agent generated and the items it returned. If the answer looks wrong,
do not fix it or change the query — report it and stop.
```

The prompt tells your agent to run the question against the app at `localhost:3000` instead of using its own Sanity tools. Your coding agent has the Sanity MCP server and can answer this question itself, correctly, without the agent you are building being involved at all. 



## Done when



### What you should see



- **One item comes back: Harissa Chickpea Bowl, $11.50.**

- **You can read the GROQ.** Find the tool call in the chat trace and read the query text. Expect one `groq_query` call, possibly preceded by `initial_context` or `schema_explorer` if your route did not fetch the schema overview into the system prompt.

- The price predicate reads `priceCents < 1200`, and a location predicate is present and dereferences to market.

- `app/app/api/agent/route.ts` no longer contains `STUB_REPLY`, and `ChatPanel.tsx` is untouched.


Count your results before you read anything else:



- **1 item, the Harissa Chickpea Bowl.** Correct. All four predicates fired.

- **2 items, Harissa plus Charred Broccoli & Farro.** The location predicate did no work. Charred Broccoli is not in Austin.

- **4 items.** The price predicate is missing.

- **0 items.** Look at `status`, the `priceCents` unit, or a market value spelled wrong.

- **Nothing, and no tools in the trace.** A token or schema problem. See Got stuck? below.

- [ ] **Send “what vegan bowls are under $12 in Austin? in the chat at `localhost:3000` yourself!**


### Agent criteria



- The route creates an MCP client against `SANITY_CONTEXT_MCP_URL` with Authorization: Bearer and your organization token, passes the endpoint's tools to streamText, and returns a UI message stream.

- `app/components/ChatPanel.tsx` is unmodified.

- The question was sent to `http://localhost:3000/api/agent`, not answered through the agent's own Sanity MCP tools.

- The GROQ text, the result count, and the returned item titles are reported verbatim.

- If the result looked wrong, it was reported and the run stopped. No query rewrites, no seed edits.


## Got stuck?



Check the token before you check the code. Most first-connection failures are not in the route.



- **403 contextGrantRequired.** `SANITY_ORGANIZATION_TOKEN` is a project token, or Context is not enabled for your organization. Both are fixed in the Dashboard.

- **`401`.** Token missing, or not sent as `Authorization: Bearer`.

- **JSON-RPC -32004.** Schema is not deployed. Run `cd studio && npx sanity schema deploy`.

- **`mcp.tools()` returns empty.** Wrong URL, or the endpoint's source ID is not `<projectId>.<dataset>`. A malformed source flips the endpoint out of GROQ mode.

- **The agent answers without calling a tool.** Your system prompt needs to say that every factual claim comes from a tool call. A model will invent a menu.

- **A query that looks right returns nothing.** Read `meta.executedQuery`, which is what actually ran.


Still stuck? [`checkpoints/1-1.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/checkpoints/1-1.md) in the starter describes what a correct result looks like. 



## Go deeper



- [Sanity Context](https://www.sanity.io/docs/ai/sanity-context)

- [Configure an MCP](https://www.sanity.io/docs/ai/sanity-context-configure-mcp)

- [Context MCP reference](https://www.sanity.io/docs/ai/sanity-context-mcp)

- [GROQ reference](https://www.sanity.io/docs/groq)

- [`skills/sanity-workshop-context-groq/references/wiring-the-route.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/skills/sanity-workshop-context-groq/SKILL.md) in the starter, for the wiring pattern in full


## Make it your own



- **Point the agent at your own dataset.** Create another MCP in the Context app with your project and dataset as its source, swap `SANITY_CONTEXT_MCP_URL` in `app/.env.local`, and deploy your schema from your Studio. The route does not change; the agent reads your schema from `initial_context`.

- **Ask a question your content should answer with one filter and a dereference,** then read the GROQ. A guessed unit or a dropped predicate looks exactly like the Austin bowl coming back as two items.

- **Keep the constraint lines when you adapt the prompt.** Every prompt names its own assumptions (the question, the field, the item). Change those and leave "run this against the app at localhost:3000" and "if the answer looks wrong, report it and stop" in place.


## Recap



The agent reads your dataset through a Context MCP in GROQ mode. Asked about vegan bowls in Austin, it composed a four-predicate query, dereferenced the location list to filter on market, and returned one bowl. You read the query it wrote.



One question produced one query shape. Next, you'll start your knowledge base build so it is ready by Mission 3. Then, in Mission 2, you'll ask three questions against this same endpoint and get three different shapes back, including one that should reach for embeddings and does not.



---

## Lesson 5: Start your knowledge base build
https://www.sanity.io/learn/course/agents-and-agentic-workflows/start-your-knowledge-base-build

Create a knowledge base from your dataset and four supplier and policy files, and start the build now so it is ready when Mission 3 adds it as a second source.

Build a knowledge base in the Context app from a Sanity dataset and a set of uploaded files, and start the build so it runs while you work through Mission 2.



### Knowledge bases, briefly



A [knowledge base](https://www.sanity.io/docs/ai/sanity-context-knowledge-bases) is an additional source for a Context MCP. A GROQ-mode endpoint queries your live dataset the moment a question arrives. A knowledge base reads its sources once, at build time, and writes a set of entries, each one reconciling everything the sources say about a topic and citing where each fact came from.



Its sources can be a dataset, uploaded files, or both. For Green & Gather that means your dataset plus four documents that were never in it: two supplier spec sheets, a section of the franchise operations manual, and the legacy nutrition site saved as a PDF.



The build takes about ten minutes to run, which is roughly how long Mission 2 takes. Start it here and it is ready when Mission 3 adds it to the agent.



### Create the knowledge base



In [sanity.io/manage](https://www.sanity.io/manage), select your organization, open **`Context` > `Knowledge Bases`** > **`New knowledge base`**. Give it this title and purpose, then click **Create knowledge base**.



**Title:** `Green & Gather — Allergens & Food Safety`



**Purpose**, pasted as is:



```text:Purpose
This Knowledge Base answers allergen, dietary, and food-safety questions for a guest-facing
assistant at Green & Gather, a fast-casual restaurant chain.

A complete result covers, for every item currently on the menu in any market: which of the nine
major allergens are present as ingredients, which may be present through cross-contact, what
substitutions are possible, and what the brand is willing to guarantee.

Completeness matters more than brevity. If an item has no allergen data, that absence is itself a
fact worth recording — do not omit the item.

Where sources disagree about whether an item is free of an allergen, always surface the more
cautious claim and flag the disagreement.
```

- [ ] Create your knowledge base


### Add your dataset as a source



Click **`Add source`**, then **`Dataset`**. Pick your project and the `production` dataset, and paste this as the query. A knowledge base binds one dataset with one GROQ query, so the policies, ingredients, recipes, and menu items all come through this single source. The query leaves out locations and prices, which stay live data for the GROQ endpoint, and anything marked internal.



```groq
*[
  _type in ["allergenPolicy", "crossContactStatement", "substitutionPolicy", "prepStandard",
            "ingredient", "supplier", "recipe", "menuItem", "faq"]
  && status != "internal"
]{
  _id, _type, title, breadcrumb, body, status, effectiveDate,
  _type == "crossContactStatement" => { verbatimStatement },
  _type in ["allergenPolicy", "substitutionPolicy"] => { policyVersion, supersedes },
  _type == "prepStandard" => { scope },
  _type == "faq" => { topic },
  _type == "supplier" => { supplierCode, specSheetRevision },
  _type == "ingredient" => { allergenTags, "supplier": supplier->{ title, supplierCode, specSheetRevision } },
  _type == "recipe" => { "ingredients": ingredients[]->{ title, allergenTags, "supplier": supplier->title } },
  _type == "menuItem" => {
    category, calories, allergens, dietaryFlags, availableFrom, availableUntil,
    "recipe": recipe->{ title, "ingredients": ingredients[]->{ title, allergenTags, "supplier": supplier->title } }
  }
}
```

Against the seed, that query returns 75 documents.



### Upload the four files



Click **`Add source`**, then **`Files`**, and upload these four from [`kb/sources/`](https://github.com/sanity-labs/sanity-agent-workshop/tree/main/kb/sources) in the starter:



- `sunfield-foods-tamarind-lime-sauce-spec.pdf`, a supplier spec sheet

- `northfield-bakehouse-flour-tortilla-spec.docx`, a supplier spec sheet

- `green-and-gather-franchise-ops-manual-section-7.pdf`, one section of the franchise operations manual

- `greenandgather-nutrition-legacy-site.pdf`, the legacy nutrition microsite saved as a PDF


### Build



Click **`Build entries`**. The build runs on its own, and the status line reads **Entries up to date** when it finishes. Go on to Mission 2 while it runs. Mission 3 opens by reading what the build found.



- [ ] Kick off your knowledge base build


Next, you'll ask three questions against your GROQ endpoint and read three different query shapes.



---

## Lesson 6: Mission 1-2: See every way it can search
https://www.sanity.io/learn/course/agents-and-agentic-workflows/see-every-way-it-can-search

Read the GROQ behind three guest questions, name the retrieval strategy each one used, and fix a wrong one from the endpoint instead of your code.

Read the GROQ behind three different guest questions to see which retrieval strategy each one uses, then correct a wrong one by changing the endpoint instead of your code.



## Set up



Nothing to build. You are using the MCP and the route from Mission 1.



- [ ] Confirm Dataset Embeddings finished generating: `cd studio && npx sanity datasets embeddings status production`. It should say ready.

- [ ] Confirm your MCP's Instructions field is still empty. The second half of this mission fills it in


## The lesson



### Three ways to search, all of them GROQ



A GROQ-mode endpoint gives your agent one query tool. Every retrieval strategy it uses is GROQ that it writes inside that tool, so the GROQ text is what tells you which strategy ran.



- **Exact filter, with `count()`.** Looks like `*[_type == "menuItem" && status == "published" && "vegan" in dietaryFlags]`. Answers questions computed over a set.

- **Keyword ranking.** Looks like `score([title, body] match text::query("…"))`. Proper nouns and exact terms, BM25-ranked, with no stemming and no fuzziness.

- **Semantic ranking.** Looks like `score(text::semanticSimilarity("…"))` then `order(_score desc)`. Matches on concepts, so the guest's wording does not have to overlap with yours. The only way to rank on prose when no field exists.


All three combine inside a single `score()`, and `boost()` rebalances them when short-field keyword hits outweigh long-field meaning.



Some questions need one of these applied several times over. A gluten-free answer means finding candidate items, then reading the ingredient tags on each candidate's recipe, then pulling the cross-contact statement. Same three strategies, one `groq_query` call per hop.



### Embeddings, briefly



An **embedding** is a numeric representation of a piece of text, positioned so that text with similar meaning sits nearby. Comparing two embeddings gives you a similarity score, which is how a query can rank "filling" against prose that never uses the word.



In Sanity, this is [**Dataset Embeddings**](https://www.sanity.io/docs/content-lake/dataset-embeddings), enabled per dataset. The `bootstrap` command turned it on for you and it generates in the background, which is why Set up has you check for `ready`. Once it is generating, `text::semanticSimilarity()` works inside `score()` in any GROQ query.



What gets embedded is a projection you choose per document type. In this dataset, a `menuItem`'s `body` is embedded as `dish_description`. On recipes and ingredients it is `preparation`, and on the policy types it is `policy_text`. Those field names are part of the embedding's semantic context. `location` and `supplier` are deliberately left out.



### What a semantic query gives back



A query using `text::semanticSimilarity()` returns two extra things on every result: a `_score`, and an `_embeddings` array holding `fragments` (the text that matched), `fields` (where the text came from), and character positions.



That array is why semantic ranking is checkable. You can point at the words that earned a score instead of trusting that the ranking was meaningful.



### Why "spicy" has no field



There is no `spicy` field in this schema and no `heaviness` field. Heat and heft live only in prose. The Thai Crunch Bowl's `body` describes "a low, sweet chili warmth… rather than sharp heat", which has zero word overlap with the word "`spicy`".



An exact filter has no field to test. Keyword ranking has no characters in common to match on. Embeddings match on concepts, which is why that bowl still sinks in the ranking for a guest who asks for something that is not `spicy`. The absence of those fields is what makes the question interesting, and adding a `spicy` boolean removes the lesson.



### The Instructions field



Every MCP has an `instructions` field, set in the Context app, up to 10,000 characters. It is injected into the agent's tool guidance, which makes it the lever for changing how your agent retrieves without changing how your app is built.



It holds only what the schema does not already make obvious. Each line should pass one test: would an agent that can read the schema still get this wrong? GROQ syntax fails that test, because the tool guidance covers it. Field lists fail it, because the schema covers them. Tone and refusals fail it, because your system prompt covers those.



## Your mission



You are going to watch your agent pick a retrieval strategy for each of three guest questions, then change one of those strategies without editing a single file.



> [!NOTE]
> **Nothing in your repo changes in this mission.** Every change happens in the Context app.



The three questions, and what each one is doing in the lesson:



- **"How many vegan options do you have?"** An exact filter plus `count()`. The answer is 7. This is the one shape a knowledge base cannot produce. Counting bowls alone gives 4, and forgetting `status` gives 8.

- **"Something filling that isn't spicy."** Should use `score(text::semanticSimilarity(…))`, and will not fire on a bare endpoint. Heat and heft are prose only. This miss is what the second half fixes.

- **"Can I get something gluten-free?"** Several calls: menu items, then recipe, then ingredient tags, then the cross-contact statement. It works. Count the calls, because that count is the cost and it recurs for every guest who asks.


Question 2 is the one that goes wrong. On an endpoint with empty instructions, the agent typically finds the `calories` field, treats `calories >= 640` as a stand-in for "filling", pulls every published item's `body` into context, and reads the prose itself to judge heat. The dishes it lands on are usually right. It steers away from the Chili Crisp Crunch Bowl and describes the Thai Crunch Bowl's chili warmth accurately. The model did the ranking work that the embedding index was built to do, and there is no `_score` and no `_embeddings` fragment anywhere in the trace to show otherwise.



Embeddings are `ready`. This is query construction, and the Instructions field is where you correct it.



Question 3 is the hinge into Mission 3. It resolves correctly, and every hop is another `groq_query` call: candidates by dietary flag, then the recipe's ingredient tags for those candidates, then the cross-contact statement. That reconciliation happens again on the next guest's question, and the one after that. Mission 3 does the same reconciliation once, at build time.



## Starting point



- Your MCP's Instructions field is empty. Open it in the Context app and confirm, since the second half of this mission is a before-and-after on that field.

- The Thai Crunch Bowl in the Studio. Read its `body`. Look for a field that says how spicy it is. There is nothing there but prose.

- A `menuItem` and its `recipe`. Compare `menuItem.allergens` against `recipe->ingredients[]->allergenTags`. They disagree on some items, which is what makes question 3 take several hops.


## The prompts



Two prompts this time, with a change in the Context app between them. Copy the first one to your coding agent, or point your agent at `missions/1-2-see-every-way-it-can-search.md` and tell it to start.



### First prompt: watch all three questions



```text:Prompt
I'm on Mission 1-2 of the Sanity agent workshop (missions/1-2-see-every-way-it-can-search.md).
Load the sanity-workshop-context-groq skill.

With pnpm dev running, ask my agent these three questions one at a time, each in a fresh
conversation, by sending them to the running app's /api/agent route. After each, show me the
exact tool calls and the GROQ it generated:
  1. "How many vegan options do you have?"
  2. "Something filling that isn't spicy"
  3. "Can I get something gluten-free?"
For each, tell me which retrieval modality did the work: exact filtering, keyword ranking
(text::query), semantic similarity (text::semanticSimilarity), or several in sequence. For
question 2, show me the _embeddings fragment that made the match — or, if nothing semantic ran,
say so plainly and show me what the agent did instead. For question 3, tell me how many
groq_query calls it took and what each one fetched.
Run this against the app at localhost:3000 — not through your own Sanity tools.
Do not change any code. Just report.
```

Each question goes in a fresh conversation so the agent cannot reuse what it already fetched. The prompt also ends with "`Do not change any code. Just report.`" Keep that line in. A coding agent handed a question it answers imperfectly will start improving the route, and reading the trace is the work here.



### Paste the deltas into Instructions



You do this part in the browser. In the Context app, open your MCP (Dashboard, then Apps, then Context, then your MCP), paste the block below into Instructions, and save.



```markdown:Instructions
### Rules

- Always filter `status == "published"` unless the user is staff. `internal` items are
  unlaunched; `deprecated` items are off the menu whatever an external source says.
- Never derive, estimate, or sum `calories`. An absent value is a fact — say it is not listed.

### Schema notes

- Heat ("spicy") and heft ("filling") exist only in `body` prose. Use
  `text::semanticSimilarity()` inside `score()` for those; there is no field to filter.
- `menuItem.allergens` is what the menu DECLARES. The full picture is
  `recipe->ingredients[]->allergenTags`. When asked about allergens, traverse the recipe.
```

Four lines, and each one states something the schema alone would not tell an agent.



### Second prompt: re-run question 2



```text:Prompt
I'm still on Mission 1-2 (missions/1-2-see-every-way-it-can-search.md). I have added instructions
to the MCP in the Context app. In a fresh conversation, send "Something filling that isn't spicy"
to the running app's /api/agent route again and show me the exact GROQ. Tell me whether
text::semanticSimilarity() fired this time, show me the _embeddings fragment that made the match,
and put the tool calls side by side with the run before.
Run this against the app at localhost:3000 — not through your own Sanity tools.
Do not change any code. Just report.
```

## Done when



### What you should see



**No files changed.** Check this first. If your agent edited the route, the constraint line was ignored, and the run needs reverting and repeating.



**Question 1** returns 7, from an exact filter with a `count()` in it.



- **7.** Correct. The filter included `status == "published"`.

- **8.** `status` was left out, so the unlaunched Winter Miso Bowl got counted. Leave it. Mission 6 makes that boundary structural.

- **4.** It counted bowls only, and vegan options are spread across bowls, wraps, salads, and sides.


**Question 2, first pass:** no `text::semanticSimilarity()` in the GROQ, no `_score` on the results, and no `_embeddings` block in the trace.



**Question 2, second pass:** one `groq_query` using `score(text::semanticSimilarity("…"))` with `order(_score desc)`, combined with a `status == "published"` filter. Results now carry `_score` and an `_embeddings` block, with a fragment from `dish_description`. Expect heft language on the bowls that rank high, and for the Thai Crunch Bowl, the phrase about low, sweet chili warmth rather than sharp heat.



Two traces, same question, same endpoint, same code, and a different retrieval strategy in each.



> [!NOTE]
> Scores land close together, within a few percent of each other, and the negation in "isn't spicy" ranks weakly. Your agent may still read `body` for the top few results. That is honest behavior for this question.



**Question 3:** three or more `groq_query` calls in sequence. Count them in the trace. The answer should name some options, mention shared prep surfaces, and stop short of promising anyone that a dish is safe.



**Then name the strategy behind each answer.** Exact filtering for question 1, semantic similarity for question 2, and for question 3, the same strategies applied once per hop. Keyword ranking may show up as a component of question 2, and it has no natural guest question of its own.



- [ ] Ask all three questions yourself, in the chat at `localhost:3000`, using a fresh conversation for each.


### Agent criteria



- No files in the repo were modified.

- All three questions were sent to `http://localhost:3000/api/agent`, one at a time, each in a fresh conversation.

- The GROQ text and tool calls are reported verbatim for each question, with a named modality.

- For question 2's first pass, the report states that nothing semantic ran and shows what happened instead.

- For question 2's second pass, the report confirms whether `text::semanticSimilarity()` fired, quotes the `_embeddings` fragment, and sets the two runs side by side.

- For question 3, the report gives a call count and says what each call fetched.


## Got stuck?



- **The semantic ranking looks random.** Check embeddings before anything else: `cd studio && npx sanity datasets embeddings status production`. A status of `updating` returns incomplete rankings with no error.

- **Question 2 still is not semantic after the paste.** Reload the MCP page in the Context app to confirm the save landed, and use a fresh conversation.

- **Your agent reports "there is no `_embeddings` field".** Every query used an explicit projection, and a projection strips it. Projecting `_embeddings[].fragments` brings it back.

- **Question 3 resolved in one call.** Allergen data has been denormalized onto the menu item. Run `pnpm verify`.

- **Question 1 says 8.** Expected on a bare endpoint. Mission 6 handles it with `groqFilter`.

- **A query looks right and returns nothing.** Read `meta.executedQuery` in the tool result, which is what actually ran.


Still stuck? [**`checkpoints/1-2.md` **](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/checkpoints/1-2.md)in the starter describes a correct result for each question.



## Go deeper



- [Context retrieval modes](https://www.sanity.io/docs/ai/sanity-context-retrieval-modes)

- [Dataset Embeddings](https://www.sanity.io/docs/content-lake/dataset-embeddings)

- [Context MCP tools](https://www.sanity.io/docs/ai/sanity-context-mcp-tools)

- [GROQ text functions](https://www.sanity.io/docs/groq-functions)

- In the starter: [skills/sanity-workshop-context-groq/references/retrieval-modalities.md](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/skills/sanity-workshop-context-groq/references/retrieval-modalities.md), and [instructions-field.md](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/skills/sanity-workshop-context-groq/references/instructions-field.md) for the full Green & Gather shortlist


## Where the queries run out



Ask these three in the chat too. They are shaped differently from the ones you just ran, and the traces are worth reading.



- **"Is the Thai Crunch Bowl peanut-free?"** Your agent will answer this, confidently, from `menuItem.allergens`. Note what it says and keep the conversation open

- **"Is your fryer shared?"** Watch the queries it tries. The answer to this is not a field on any document in your dataset

- **"Can you make the harissa wrap gluten-free?"** A guest asking this wants a commitment, and the trace will show your agent assembling one out of fields


Two things separate these from question 1. Each has a single correct answer rather than a set to compute over, and that answer is stated somewhere instead of being derived from a filter. Question 3 was already halfway here: it worked, and it cost several calls to reconcile documents that each held part of the answer. Every guest who asks pays that cost again.



That reconciliation can happen once, ahead of time, over sources that include documents Sanity never held.



## Make it your own



- Ask for "gluten free" without the hyphen. In testing this sent the agent down a different path, using a text search rather than checking `"gluten-free-option" in dietaryFlags`. Exact filters are brittle against how people actually type

- Rephrase question 2 as "what's your heaviest bowl" or "something light". Watch where semantic ranking helps and where it produces a near-tie

- Ask for a count of something else, like how many items contain sesame, and see whether the agent reaches for `count()` or fetches everything and counts in its own reasoning


## Recap



One endpoint answered three questions three different ways: a filter with a count, a semantic ranking over prose, and a sequence of calls walking from a menu item to a cross-contact statement. Question 2 picked the wrong strategy, and four lines in the Instructions field corrected it while your code stayed still.



Next, you'll add a knowledge base as a second source for the agent, built from your dataset plus four files. It answers the gluten-free question in one cited lookup, and it has something to say about the Thai Crunch Bowl.



---

## Lesson 7: Mission 1-3: Add a knowledge base as a second source
https://www.sanity.io/learn/course/agents-and-agentic-workflows/add-a-knowledge-base-as-a-second-source

Add a knowledge base as a second source, and watch a question that took several GROQ round trips become one cited lookup.

Add a knowledge base as a second source for your agent, so it can answer from documents that were never in your dataset.



## Set up



> [!NOTE]
> Knowledge Bases are an opt-in early access feature, free during beta. You create yours on the same Context app page where you made your MCP.



You started your build in the lesson after Mission 1. Now you finish it: read what the build found, add three instructions, and create a second MCP in Knowledge Base mode. About five minutes of clicking, all of it in the Context app.



### 1. Read what the build found



- [ ] Confirm the status line reads **Entries up to date**


Open **Issues**. Every build reads the same sources and reconciles them a little differently, so your list will not match anyone else's. One build surfaces a spelling difference between a description and its source. Another catches the allergen matrix in one entry disagreeing with the ingredient list in another, or a menu item declaring an allergen its recipe never carries. All of it is your content disagreeing with itself, found before an agent asked a single question.



Read each issue and resolve it toward the source that should win: the current policy over an older document, a dated supplier specification over a menu field, a recipe's ingredients over a hand-written matrix. Accepting a claim writes the matching instruction for you, anchored to the right source. [Resolve ingest issues](https://www.sanity.io/docs/ai/sanity-context-resolve-issues) in the docs covers the panel in detail.



While you are in there, look for two things this mission relies on: an entry for the Thai Crunch Bowl that cites the Sunfield Foods specification, and how the build handled the gluten-free wrap substitution, where the March 2025 operations manual and the current substitution policy disagree.



- [ ] Every issue in the panel resolved


### 2. Add three instructions and rebuild



Under **Instructions**, add these three. Each one is anchored to a source. Note: to add instructions to a dataset, use the checkboxes to select dataset records.



- **Anchored to the dataset.** Never phrase an entry as an assurance of safety. Write "contains no peanut ingredients" or "produced on equipment that also processes peanuts," never "is peanut-free," "is safe for," or "suitable for." Every entry that answers an allergen question ends with the cross-contact statement reproduced exactly as written in the Cross-Contact Statement document, not paraphrased or shortened.

- **Anchored to the dataset.** Write one entry per menu item, not per category. Every allergen claim names its source: the declared allergen list, an ingredient record, a named supplier specification with its revision date, the Franchise Operations Manual, or the legacy nutrition site. A menu item's declared allergen list covers ingredients only; where an ingredient record or a supplier specification shows something the declared list does not, record both and mark the entry as an exception. Include a calorie figure only where the menu item record has one, and never derive or sum one.

- **Anchored to the ops manual.** Operational facts in the Franchise Operations Manual that no other source contradicts remain valid and are recorded with the March 2025 date, including the front-of-house intake script, escalation triggers, reaction reporting, and which items are made in store (guacamole, pickles, grains) versus delivered sealed from the commissary (sauces and dressings).

- [ ] Three instructions added, and the knowledge base rebuilt to **Entries up to date**


### 3. Create a second MCP in Knowledge Base mode



Back on **MCP Endpoints**, create a second MCP whose only source is this knowledge base. Name it carefully; the name is in the URL and cannot change. Paste this into its **Instructions** field. The agent receives it on connect, and it says what the endpoint holds without telling the agent when to use it. 



```text:Instructions
This endpoint serves the Green & Gather allergen and food safety knowledge base. It answers guest questions about allergens, cross-contact, substitutions, kitchen preparation, and what the brand does and does not guarantee.

Retrieving. Start from the outline. Entries tagged [core] are the per-item allergen entries and the current policies; read those first. When several entries look relevant, read them in one call rather than one at a time. Each menu item has its own entry; for a question about a dish, read that dish's entry together with the cross-contact statement. Entries cite their sources. Carry the citation into your answer by naming the source and its date, for example "Sunfield Foods specification, March 2026."

Answering. Never tell a guest an item is safe, allergen-free, or suitable for their allergy. State what the ingredients contain and what cross-contact is possible, and let the guest decide. End every allergen answer with the cross-contact statement exactly as it appears in the knowledge base, not shortened or paraphrased. Where an entry records that sources disagree, give the more cautious claim first, say that sources disagree, and name them; do not choose the reassuring one. Where an entry marks a claim as superseded or outdated, do not repeat it as current. Where an entry says a figure is not published, say so rather than estimating. If the guest describes a severe allergy or asks about a supplier's manufacturing practices, recommend they speak with the manager on duty before ordering.

Not held here. Prices, which locations serve an item, opening hours, counts or lists of items matching a filter, and anything about unreleased items. Those come from live menu data. If asked, say this knowledge base does not cover it rather than inferring from an entry.
```

After you save, copy the endpoint URL into `app/.env.local` as `SANITY_CONTEXT_KB_URL`. It is shaped like your GROQ endpoint, `api.sanity.io/v1/context/organizations/<orgId>/mcp/<name>`. Your knowledge base is in your own organization, so `SANITY_ORGANIZATION_TOKEN` serves both endpoints.



- [ ] Second MCP created with the knowledge base as its only source, and its URL in `app/.env.local` as `SANITY_CONTEXT_KB_URL`

> [!WARNING]
> **Still building?** The status line tells you. A build takes roughly ten minutes, and nothing in this mission works until it reads Entries up to date. Wait it out rather than wiring a half-built index.



## The lesson



### What your build did



Your knowledge base read two kinds of source: your dataset, through one GROQ query, and four uploaded files.



The gluten-free answer your agent assembled from several GROQ calls in Mission 2 is now a written entry with the policy conflict already resolved inside it. Every guest who asks reads the same settled entry.



Three of those four files have no GROQ expression at all. A supplier's PDF spec sheet, a franchise operations manual, and a saved copy of a legacy microsite are not documents Sanity ever held, and no query you write can reach them.



### The two tools KB mode serves



- `initial_context` returns the outline: every entry path with a one-line summary, tagged [core] or [peripheral].

- `knowledge_base_read` takes {knowledgeBase: "kb…", paths: [...]} and returns those entries. Up to 20 paths in one call, each path copied verbatim from the outline.


Retrieval here is path navigation over an index built at ingest. Embedding search lives on the GROQ side of your agent, which is what Mission 2 was about.



The practical consequence is the call count. An agent that reads the outline first can then fetch everything it needs in a single `knowledge_base_read`, because it already knows which paths exist.



### Mode comes from sources, and the dataset wins



An MCP whose sources are all Knowledge Bases serves the KB tools. An MCP with a dataset source serves the GROQ tools. An MCP with both serves the GROQ tools, and the Knowledge Bases are silently ignored.



That silent part is why this is a second endpoint rather than a source added to the one from Mission 1. Adding your knowledge base to the existing MCP would look like it worked and change nothing.



`groqFilter` has no effect in KB mode. Scope here is which Knowledge Bases an endpoint serves.



### Two reasons to reach for a knowledge base



You now have both kinds of source wired to one agent, so the comparison is worth making precisely. GROQ retrieves. A knowledge base reconciles.



1. **The reconciliation is expensive and repeats.** Several documents each hold part of an answer, and assembling it per request costs a call per hop for every guest who asks.

2. **A source has no GROQ expression.** The fact lives in a PDF, a spreadsheet, or a site nobody owns anymore.


Speed and cost are not among the reasons. GROQ beats a knowledge base on any simple filter, and GROQ is the only one of the two that can compute: count, compare, sort, aggregate. A build-time index has nothing to count.



### The debug levers are yours



You built this knowledge base, so its behavior is yours to change.



- **A fact is missing.** Add a source.

- **A fact is skipped or under-weighted.** `purpose`.

- **Wrong shape, or no citation.** `instructions`.

- **Sources disagree.** Accept a claim in Issues, which writes the instruction.

- **The agent never looked at the knowledge base.** Routing, which is Mission 4.

> [!WARNING]
> **Never hand-edit an entry.** A rebuild overwrites it. Uploaded files never re-sync either: to update one, delete the import and upload it again.



## Your mission



You are giving the agent a second place to look, one that has already done the reconciliation your GROQ queries were doing per request.



In `app/app/api/agent/route.ts`, that means a second MCP client next to the first, both tool sets merged, and both registered on `streamText`. The first source stays. `ChatPanel.tsx` stays.



> [!WARNING]
> **Both endpoints serve a tool called `initial_context`.** A naive spread of one tool object into another lets the second endpoint's version overwrite the first, and one of your two sources goes quiet without an error. Strip or rename it before merging, and give the KB's tool a name that says what it is.



Then three questions, one at a time, each in a fresh conversation:



- **"Is the Thai Crunch Bowl peanut-free?"** The planted payoff. Six sources, four answers.

- **"Is your fryer shared?"** No field in your dataset holds this answer.

- **"Can I get something gluten-free?"** Mission 2's expensive question, asked again against a second source.


### What the peanut question is actually asking



Six sources hold something about peanuts and the Thai Crunch Bowl:



- **`menuItem.allergens`.** Soy, wheat, sesame. No peanut.

- **`recipe->ingredients[]->allergenTags`.** No peanut either.

- **`gg.faq.thai-crunch-peanut`.** States outright that it is peanut-free.

- **Legacy nutrition site matrix.** Peanut column left blank.

- **Sunfield Foods spec sheet, Rev 4.2.** Made on equipment that also processes peanuts. The supplier cannot certify it free from peanut protein.

- **Cross-contact statement.** The verbatim statement the brand will stand behind.


GROQ alone answers from the declared allergen field, confidently, and is wrong in the direction that matters. It did exactly what it was asked. The question was never a query.



> [!NOTE]
> **Your agent may still send this question to `groq_query`.** If it does, report it and leave it. Having two sources available is a different thing from knowing which one to use, and Mission 4 is where you fix that.



## Starting point



- Your knowledge base's **Entries** page. One entry per menu item, plus the policies. Open the Thai Crunch Bowl's entry and look for the Sunfield Foods citation. If it is not there, that upload did not land, and the payoff of this mission will not fire.

- `app/app/api/agent/route.ts`. One MCP client, one tool set. Note how the tools are handed to `streamText`, since that is the line the merge changes.

- Your Mission 2 gluten-free conversation, if it is still open in the chat. Count the `groq_query` calls in that trace and write the number down. That is your baseline.


## The prompt



Copy this to your coding agent, or point your agent at `missions/1-3-add-a-knowledge-base.md` and tell it to start.



```text:Prompt
I'm on Mission 1-3 of the Sanity agent workshop (missions/1-3-add-a-knowledge-base.md). Load
the sanity-workshop-knowledge-bases skill.

I have added SANITY_CONTEXT_KB_URL to app/.env.local. That endpoint is a Sanity Context MCP in
Knowledge Base mode, pointed at the "Green & Gather — Allergens & Food Safety" knowledge base I
built in my own organization, so it takes my SANITY_ORGANIZATION_TOKEN as its bearer.

In app/app/api/agent/route.ts, add it as a second MCP source alongside the existing GROQ-mode
one, so the agent holds both tool sets. Watch for the initial_context name collision. Do not
change ChatPanel.tsx and do not remove the first source.

Then, with pnpm dev running, ask my agent these questions one at a time, fresh conversation
each, via the running app's /api/agent route, and show me the tool calls each one made:
  1. "Is the Thai Crunch Bowl peanut-free?"
  2. "Is your fryer shared?"
  3. "Can I get something gluten-free?"
For each, tell me which source answered, how many calls it took, and which sources the answer
cited. Run this against the app at localhost:3000 — not through your own Sanity tools. If the
agent answers a question from the wrong source, do not fix it — report it; that is the next
mission.
```

A wrong-source answer is one of the two results this mission is looking for, and an agent that quietly corrects it removes the observation.



## Done when



### What you should see



- [ ] Ask all three questions yourself, in the chat at `localhost:3000`, with a fresh conversation for each. 


**Both tool sets survived the merge.** The trace should show `knowledge_base_read` available alongside `groq_query`. If only one endpoint's tools are present, the `initial_context` collision ate the other.



**"Can I get something gluten-free?"** resolves in one `knowledge_base_read`, against the several `groq_query` calls it took in Mission 2. The answer names the `gluten-free-option` items, says the current substitution policy does not promise a gluten-free wrap swap because of shared prep surfaces, and notes that the March 2025 operations manual said otherwise and has been superseded. It may cite the Northfield tortilla sheet.



**"Is your fryer shared?"** takes one knowledge base lookup. The answer is yes, shared fryer and shared prep surfaces, quoting the prep standard or the cross-contact statement.



**"Is the Thai Crunch Bowl peanut-free?"** has two possible outcomes, and both are the mission working.



- **One `knowledge_base_read`.** The answer says the bowl contains no peanut ingredients, that its tamarind-lime sauce is made on equipment that also processes peanuts, and that the supplier cannot certify it free of peanut protein. It cites the Sunfield Foods spec sheet and ends with the cross-contact statement. Read it closely: it never says "peanut-free" and never says "safe". A PDF that was never in your dataset just changed a guest-facing answer.

- **`groq_query` fired, and the answer was some version of "yes, no peanut is declared."** The agent picked the wrong source and gave a confidently wrong answer in the dangerous direction. This is the correct observation, and Mission 4 is where routing gets fixed. Leave it.


### Agent criteria



- A second MCP client was added against `SANITY_CONTEXT_KB_URL`, with `SANITY_ORGANIZATION_TOKEN` as its bearer.

- The first GROQ-mode source is still registered, and `app/components/ChatPanel.tsx` is unmodified.

- The `initial_context` collision was handled by stripping or renaming, and both tool sets reach `streamText`.

- All three questions were sent to `http://localhost:3000/api/agent`, one at a time, in fresh conversations.

- For each question, the report names which source answered, the call count, and the sources the answer cited.

- Any wrong-source answer was reported rather than corrected.


## Got stuck?



- **HTTP `405`.** `SANITY_CONTEXT_KB_URL` is the Context app's browser address for the knowledge base. Use the `api.sanity.io/v1/context/organizations/…/mcp/<name>` URL the app shows for the MCP.

- **`403` naming the knowledge base.** The wrong token reached the KB endpoint. It takes the same `SANITY_ORGANIZATION_TOKEN` as the GROQ endpoint.

- **JSON-RPC `-32005`.** No readable knowledge base at that endpoint. Either the URL is your GROQ endpoint, or the MCP has a dataset source as well, so the dataset won and the knowledge base is being ignored.

- **Only one `initial_context`, or a whole tool set missing.** The spread collision. Strip or rename before merging.

- **The peanut answer cites nothing, or never mentions Sunfield.** The spec sheet did not ingest. Check the knowledge base's sources for a failed import and re-upload it from `kb/sources/`.

- **The agent says the gluten-free swap is available on request.** It read the March 2025 manual over the current policy. This is a knowledge base `instructions` lever rather than a code fix: open the gluten-free issue, accept the current-policy claim, and rebuild. If Issues never raised it, check that the ops-manual instruction is in place and rebuild.

- **The peanut question still fires `groq_query`.** Routing, which is Mission 4. Expected.

- **Your build is still running.** Wait for Entries up to date before wiring the endpoint. A partially built index answers badly and looks like a code problem.


Still stuck? `checkpoints/1-3.md` in the starter describes a correct result for each question.



## Go deeper



- [Knowledge Bases](https://www.sanity.io/docs/ai/sanity-context-knowledge-bases)

- [Create a Knowledge Base](https://www.sanity.io/docs/ai/sanity-context-create-knowledge-base)

- [Source types](https://www.sanity.io/docs/ai/sanity-context-source-types)

- [Resolve ingest issues](https://www.sanity.io/docs/ai/sanity-context-resolve-issues)

- [Context MCP tools](https://www.sanity.io/docs/ai/sanity-context-mcp-tools)

- In the starter: [`skills/sanity-workshop-knowledge-bases/references/what-the-kb-knows.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/checkpoints/1-3.md)


## Two tool sets, no router



Ask these two in the chat, back to back:



- "Anything with no sesame?"

- "Is it safe for my sesame allergy?"


One word apart. The first is a filter over declared allergen data, which is GROQ's job. The second is a question about what the brand will guarantee, which belongs to your knowledge base. Ask each of them three times and watch where they land.



Nothing in your agent inspects a question and picks a source. Sanity Context's tool names and descriptions are deliberately generic: `groq_query` describes a mechanism and says nothing about a domain. The model is choosing from a flat list of tools by name and description alone.



## Make it your own



- Ask whether the guacamole is made in store. That fact lives in the franchise operations manual and nowhere in your dataset. The answer should carry the March 2025 date

- Ask whether the Summer Peach Bowl is available. The legacy site says "Now Serving". Watch the answer handle a superseded claim

- Ask for the price of the Thai Crunch Bowl. The knowledge base's Instructions text declares that prices are not held there. See what your agent does with a question its second source explicitly refuses


## Recap



Your agent holds two sources now. Your knowledge base did its reconciliation once, at build time, across your dataset and four documents Sanity never stored, and the gluten-free question that cost several calls in Mission 2 comes back in one lookup. The peanut answer's decisive fact came out of a supplier PDF.



Two sources also means two flat lists of tools and no router between them. Next, you'll become the router, using tool descriptions and a routing table to send those two sesame questions to two different doors.



---

## Lesson 8: Mission 1-4: Teach it which source to use
https://www.sanity.io/learn/course/agents-and-agentic-workflows/teach-it-which-source-to-use

Give an agent with two overlapping sources a routing table and domain-loaded tool descriptions, so each question reaches the right one.

Make an agent with two overlapping sources send each question to the right one, using a routing table and domain-loaded tool descriptions.



## The lesson



### There is no built-in router



Every backend you connect exposes its tools into one flat list. The model picks from that list by name and description, and there is no layer beneath it inspecting the question and choosing a source.



 `groq_query` describes a mechanism and names no domain, which is correct for an endpoint that could be pointed at any dataset. It also means that once you have two plausible sources, nothing in the tool list tells the model which of them owns a given question. You supply that. **You are the router.**



### Who owns which answer



Ownership comes first, and everything in the levers below is an encoding of it. For reference, here is how it falls out for Green & Gather:



- **Menu facts computed over a set** (what is vegan, under $12, in Austin, how many, calories, availability dates) belong to your Sanity dataset, through `groq_query`, re-described.

- **What the brand is willing to guarantee** (allergen safety, cross-contact, substitutions, policy, supplier statements) belongs to your knowledge base, through `knowledge_base_read`, re-described.

- **Live state right now** (sold out, open or closed) belongs to the shop system in `app/lib/shop.ts`. Not an MCP. The route calls it directly.

- **Who is asking** belongs to the loyalty CRM in `app/lib/loyalty.ts`, injected server-side, which is Mission 5.


The rule of thumb underneath it: your dataset owns "what is this thing and how do we describe it." Your knowledge base owns facts reconciled across sources. Operational systems own "what is its live state right now."



Two of those four are not Context endpoints at all. Routing is a question about who owns an answer, and some answers belong to systems your agent reaches by other means.



### The four levers, in order



**1. A routing table in the system prompt.** The most improvement for the least work, so start here.



```text:System prompt
Pick the source by the kind of question, not the topic:
- Menu facts computed over a set — which items, how many, prices, calories, markets, dates —
  use the menu content tools (GROQ). Filtering on declared allergens ("anything with no
  sesame?") is a menu fact.
- Anything about safety, guarantees, cross-contact, substitutions, or policy — "is it safe
  for my allergy?", "is the fryer shared?", "can you make it gluten-free?" — use the
  allergen & food-safety knowledge base. Never answer these from menu fields.
- If a guest has an allergy on file, every recommendation also needs a knowledge-base check.
- Live availability (sold out, hours) is neither; say you will check with the store.
```

Sorting by the *kind* of question rather than the topic is what makes it work. Both sesame questions are about sesame. Only one is computed over a set.



**2. Re-describe the generic tools.** What comes back from `mcp.tools()` is a plain object, so the keys and descriptions are data you can rewrite before handing them to the model.



```typescript:app/app/api/agent/route.ts
const tools = {
  query_menu: {
    ...groqTools.groq_query,
    description:
      'Query the Green & Gather MENU dataset with GROQ: menu items, prices, calories, dietary flags, ' +
      'declared allergens, recipes, ingredients, locations, markets. Use for anything computed ' +
      'over a set — which items, how many, under a price, in a market. NOT for allergen safety, ' +
      'cross-contact, substitutions, or policy; those come from the knowledge base.',
  },
  // read_food_safety_kb takes the same shape over kbTools.knowledge_base_read,
  // with its own boundary: NOT for counts, prices, or listings.
}
```

The name carries the domain and the description carries the boundary. That `NOT for` clause is the part people leave out, and it is usually the part that fixes a stubborn misroute. Fewer, more distinct tools route better than many overlapping ones, so drop any tool that is never the right answer to a question you expect.



**3. Make the boundary structural.** A tool description asks the model to behave. Stating in the GROQ endpoint's `instructions` field that cross-contact and supplier statements are not stored in the dataset means a misroute comes back empty and the model corrects itself. Structural scoping fails safe.



**4. Pre-classification or sub-agents.** A cheap first call decides "menu" or "safety" and exposes only that source's tools. More than a workshop has room for.



### Why this pair is sharp



The two sesame questions read as the same question. One is a set operation over data you have declared. The other is a liability statement about what you are willing to promise a guest with an allergy.



Getting the first wrong returns an awkward answer. Getting the second wrong means answering a safety question from a field that was never meant to carry that weight.



## Your mission



You are going to make your agent pick the right source for a question reliably, by giving its tools names and descriptions that say what each one is for and what it is not for.



This mission runs in two phases, and the first one changes nothing.



**Phase one: measure.** Ask both sesame questions three times each, in fresh conversations, and record which tool fired every time. You need to know what your agent does before you correct it, and "it went to the right source once" is not the same as reliable.



**Phase two: correct, if it needs correcting.** If the two questions are already landing on the right sources three times running, you are done and there is nothing to change. If they are not, apply the first two levers: a routing table in the system prompt, and re-described tools. Then run the same three-times test again and compare.



> [!NOTE]
> Two changes and nothing else. The point of holding the scope down is that you can attribute the improvement.



## Starting point



- `app/app/api/agent/route.ts`. Look at the tool names your route hands to `streamText` right now. They came straight from the two endpoints: `groq_query`, `knowledge_base_read`, and whatever survived your Mission 3 merge. Every one of them describes a mechanism.

- Your system prompt. Read what it currently says about choosing a source. It almost certainly says nothing.

- Your sesame conversations from Mission 3, if they are still open. That informal test is about to become a measured one.


## The prompt



Copy this to your coding agent, or point your agent at `missions/1-4-teach-it-which-source-to-use.md` and tell it to start.



```text:Prompt
I'm on Mission 1-4 of the Sanity agent workshop (missions/1-4-teach-it-which-source-to-use.md).
Load the sanity-workshop-knowledge-bases skill and read its references/routing.md.

My agent in app/app/api/agent/route.ts has two sources: a GROQ-mode Context MCP over my menu
dataset and a Knowledge-Base-mode MCP over the allergen & food-safety knowledge base.
There is no built-in router; the model picks tools by name and description.

First, with pnpm dev running, ask these two questions three times each, fresh conversation every
time, via the running app's /api/agent route, and show me which tool each one used:
  A. "Anything with no sesame?"
  B. "Is it safe for my sesame allergy?"
Run this against the app at localhost:3000 — not through your own Sanity tools.

Then, only if they are not consistently landing on the right source (A → the menu dataset via
GROQ, B → the knowledge base): add a routing table to the system prompt based on the
source-of-truth table in the skill, and re-describe the generic Sanity tools with domain-loaded
names and descriptions that say what each is for and what it is NOT for. Change nothing else.
Repeat the three-times test and show me the before and after.
```

The "only if" clause is deliberate. An agent that rewrites the prompt before measuring anything leaves you unable to say whether the rewrite helped.



## Done when



### What you should see



- [ ] Ask both questions yourself in the chat at `localhost:3000`, three times each, fresh conversation every time. Six conversations. Watch which tool name appears in the trace


**"Anything with no sesame?"** goes to the menu tool every time. The GROQ filters on `allergens` and the answer lists items. Citrus Fennel Salad, House Pickles, and Cheese Quesadilla are among them, with more across the menu.



**"Is it safe for my sesame allergy?"** goes to the knowledge base tool every time. The answer covers shared surfaces and what the brand will guarantee, and it ends with the cross-contact statement.



Your run lands in one of two places, and both count:



- **Inconsistent.** Both questions to GROQ some of the time, or both to the knowledge base, or a different answer per run. This is the common result. Apply the two levers and run the test again. The before-and-after is the observation.

- **Already consistent, three times running.** Your Mission 3 system prompt was doing more work than expected. Nothing to change, and the mission is done. Read the levers anyway, because the next agent you build will need them.


If you did make changes, exactly two things changed: a routing table in the system prompt sorting by the kind of question rather than the topic, and tool names and descriptions each carrying a domain and a NOT for boundary.



The endpoints did not change. The knowledge base did not change. The dataset did not change. Everything that improved here improved because the model was told what its tools are for.



### Agent criteria



- Phase one ran before any edits: both questions, three times each, in fresh conversations, with the tool used recorded for all six.

- All questions were sent to `http://localhost:3000/api/agent` rather than answered with the agent's own Sanity tools.

- Changes, if any, are limited to the system prompt and the tool names and descriptions in `app/app/api/agent/route.ts`.

- Re-described tools carry both a domain and an explicit boundary clause.

- The three-times test was repeated after the change, and the before and after are shown side by side.

- No changes to `ChatPanel.tsx`, the endpoints, or the knowledge base.


## Got stuck?



- **"Is it safe for my sesame allergy?" still goes to GROQ.** The GROQ tool's description needs an explicit boundary: `NOT for allergen safety, cross-contact, or policy`. A description that only says what a tool is for leaves the model to infer the edges.

- **"Anything with no sesame?" goes to the knowledge base.** Your routing table needs to say that filtering on declared allergens is a menu fact. The word "allergen" in the question is pulling it toward the knowledge base, which is exactly the topic-versus-kind trap.

- **The same question routes differently on different runs.** Check that each attempt is a genuinely fresh conversation. A follow-up in an existing thread inherits the tool choice that came before it.

- **A whole tool set is missing from the trace.** That is the `initial_context` collision from Mission 3. Fix the merge before you judge the routing.

- **Routing improved but you cannot say why.** Two changes went in at once. Revert the tool descriptions, keep the routing table, and re-test to see how much each lever was worth.


Still stuck? [`checkpoints/1-4.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/checkpoints/1-4.md) in the starter describes what the after state looks like.



## Go deeper



- [Sanity Context patterns](https://www.sanity.io/docs/ai/sanity-context-patterns), including multi-backend agents

- [Context MCP tools](https://www.sanity.io/docs/ai/sanity-context-mcp-tools)

- [Context retrieval modes](https://www.sanity.io/docs/ai/sanity-context-retrieval-modes)

- In the starter: [`skills/sanity-workshop-knowledge-bases/references/routing.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/skills/sanity-workshop-knowledge-bases/references/routing.md), the four levers with the full Green & Gather routing table


## Who's asking?



- [ ] Ask this one in the chat: "What do you recommend?"


Read the answer, then read it again as somebody with a peanut allergy. It is the same answer either way, because nothing in your agent knows who is on the other end of the conversation.



There is a line in the routing table you just wrote that has no way to fire yet: *if a guest has an allergy on file, every recommendation also needs a knowledge-base check.* Your agent has no idea whether anyone has an allergy on file.



## Make it your own



- Test the other two questions from the routing table. "How many vegan options do you have?" should go to the menu tool, because a knowledge base has no way to count. "Is the Thai Crunch Bowl peanut-free?" should go to the knowledge base, and if Mission 3 sent that one to GROQ, this is where it gets fixed

- Ask a question that needs both. "What vegan bowls can I safely eat with a sesame allergy in Austin?" should hit the menu first for candidates, then the knowledge base for each candidate's sesame concern. Cross-system answers are the goal rather than a failure, and the ordering is something you can guide in the prompt

- Delete one `NOT for` clause and run the three-times test again. It is a fast way to see how much of the routing was carried by that one sentence


## Recap



Your agent routes by the kind of question now. A filter over declared allergen data goes to the menu dataset, a question about what the brand will guarantee goes to the knowledge base, and the pair holds three times running. Nothing about your content or your endpoints changed. The tools were given names and boundaries, and the system prompt was given a table.



Next, you'll add the second axis. Routing by the kind of question is one half; routing by who is asking is the other, and a saved peanut allergy turns an ordinary recommendation into a recommendation plus a mandatory safety check.



---

## Lesson 9: Mission 1-5: Personalize it
https://www.sanity.io/learn/course/agents-and-agentic-workflows/personalize-it

Inject a signed-in guest's profile into the system prompt server-side, so the agent's answers change without any of it entering your content.

Personalize an agent's answers for a signed-in user by injecting their profile into the system prompt server-side, without storing any of it in your content.



## The lesson



### Signals ride on top of your content



The pattern, in one sentence: identify the user from the verified session, pull their signals from the systems that own them, inject those signals into the system prompt as trusted input, and let the agent shape its queries and its ranking around them.



Sanity stays the menu layer. A guest profile is owned by a CRM, and moving it into your dataset would make your content layer responsible for personal data it did not create, cannot keep current, and has no business storing.



### What the loyalty stub holds



`app/lib/loyalty.ts` stands in for that CRM. It has two paths, and the mission needs both.



- `getGuest('dara')` returns a gold-tier guest: allergens ['peanut'], vegetarian, usual order gg.menuItem.thai-crunch-bowl, home location gg.location.austin-south-congress.

- `getGuest(null)` returns null, the anonymous control.


Dara's usual order is the Thai Crunch Bowl, which is the item carrying the Sunfield cross-contact concern from Mission 3. Her profile and your knowledge base are about to meet.



### Enrich and inject



Read the guest server-side, build a block, append it to the base prompt when a guest resolves:



```typescript:app/app/api/agent/route.ts
const {messages, guestId} = await req.json()
const guest = getGuest(guestId) // null for anonymous

const guestBlock = guest
  ? `
Guest profile (server-provided, trusted):
- Name: ${guest.name} (${guest.tier} member)
- Allergens on file: ${guest.allergens.join(', ') || 'none'}
- Dietary preferences: ${guest.dietaryPrefs.join(', ') || 'none'}
- Home location: ${guest.homeLocation}
- Usual order: ${guest.usualOrder ?? 'none'}

For this guest, check allergen facts for anything you recommend, unprompted. A saved allergy
makes the cross-contact statement non-optional.`
  : ''

const system = BASE_PROMPT + guestBlock
```

The instruction to check allergens sits inside the guest block. Putting it in the base prompt instead would make the anonymous run behave identically, and the before-and-after is the only thing that makes this mission visible.



### The groqFilter trap



The instinct here is a per-user `groqFilter`. A `groqFilter` narrows what the agent is able to see at all, and nothing on Dara's profile should make a menu item invisible to her. Her profile changes what gets checked and how results rank.



Per-user `groqFilter` belongs to a different problem: user-owned documents that genuinely live in your dataset, scoped by a session-derived id alongside a fail-closed base filter.



### Boundaries that keep this honest



> [!WARNING]
> **The profile never enters Sanity.** Not as a document, not as a schema field.


> [!WARNING]
> **Identity comes from the verified session** in a real app. The chat panel sends `guestId` in the request body as a stand-in for that, and a request body is attacker-controlled. Leave a comment saying so rather than letting the shortcut read as the pattern.


> [!WARNING]
> **Keep the profile out of your logs.** It is sensitive at the app layer even when the guest is fictional.



### The second routing axis



Mission 4 routed by the kind of question. This routes by who is asking.



Anonymous, "what's good here?" is a catalog question and GROQ answers it. For Dara it is a catalog question plus a mandatory knowledge base lookup, because a peanut allergy makes the Sunfield cross-contact fact non-optional. Same routing skill, applied to the person instead of the sentence, and it adds a row to the table you wrote in Mission 4.



## Your mission



You are going to make the same question produce a different answer for a signed-in guest, by reading her profile server-side and handing it to the model as trusted context.



Three moves in `app/app/api/agent/route.ts`:



1. **Read `guestId`** from the request body, with a comment noting that identity comes from the verified session in a real app.

2. **Resolve it** with `getGuest()` from `app/lib/loyalty.ts`.

3. **Append a guest block** to the system prompt when a guest resolves, carrying the allergens, dietary preferences, home location, usual order, and the instruction to check allergen facts unprompted.


The anonymous path has to keep working exactly as it did. It is the control, so a `null` guest needs to be a real path rather than an edge case.



> [!NOTE]
> Four things to leave alone: no `groqFilter` changes, no writes to Sanity, no schema changes, and no edits to `ChatPanel.tsx`.



Then ask **"What do you recommend?"** twice. Once as `dara`, once anonymously.



## Starting point



- `app/lib/loyalty.ts`. Read the whole file, including the comment block at the top. It states the rules that make this lesson hold, and one of them is the trap you are about to be tempted by.

- `app/app/api/agent/route.ts`. Look at how the request body is destructured. `guestId` is arriving in every request and nothing is reading it.

- The chat at `localhost:3000`. There is a guest picker above the input. Select `dara`, ask "What do you recommend?", and read the answer now. Nothing in your route knows who Dara is yet, so this is your baseline.


## The prompt



Copy this to your coding agent, or point your agent at `missions/1-5-personalize-it.md` and tell it to start.



```text:Prompt
I'm on Mission 1-5 of the Sanity agent workshop (missions/1-5-personalize-it.md). Load the
sanity-workshop-context-groq skill and read its references/personalization.md.

app/lib/loyalty.ts is a stub for a loyalty CRM with two seeded guests; "dara" has a peanut
allergy on file. The ChatPanel already sends guestId in the request body to /api/agent (in a
real app this would come from the verified session — leave a comment saying so).

In app/app/api/agent/route.ts, read guestId, resolve it with getGuest(), and inject the
guest's signals into the system prompt as trusted, server-provided context — allergens,
dietary preferences, home location, usual order — with an instruction to check allergen facts
unprompted for anything you recommend. Anonymous (null) must keep working exactly as before.
Do NOT put the profile in a groqFilter, do NOT write anything to Sanity or the schema, and do
NOT change ChatPanel.tsx.

Then, with pnpm dev running, ask "What do you recommend?" twice via the running app's /api/agent
route: once with guestId "dara", once with guestId null. Show me both answers and the tool calls
each made. Run this against the app at localhost:3000 — not through your own Sanity tools.
```

## Done when



### What you should see



- [ ] Use the guest picker in the chat at `localhost:3000`. Ask "What do you recommend?" as `dara`, then switch the picker to anonymous and ask the identical question in a fresh conversation


**As `dara`,** the trace shows menu queries and an allergen check. That check is a `knowledge_base_read` if you finished Mission 3, or a `groq_query` into the recipe's allergen tags and the cross-contact statement if you did not. The answer:



- recommends real menu items, vegetarian, because that preference is on file

- flags or avoids peanut exposure without being asked

- does not recommend her usual Thai Crunch Bowl without raising the Sunfield cross-contact concern, assuming your knowledge base is connected

- may scope to Austin


**Anonymously,** menu queries only. A friendly recommendation, no allergen check, no mention of peanuts.



- **Two answers that read the same.** The guest block is not reaching the system prompt. Log its length.

- **The anonymous answer also checks allergens.** The check instruction landed in the base prompt. Move it inside the guest block.

- **A per-guest `groqFilter` appeared in the route.** The trap. Revert it. The profile belongs in the system prompt.

- **Dara's answer recommends the Thai Crunch Bowl with no caveat.** The allergen check ran but the result is not shaping the recommendation. Strengthen the instruction in the guest block.


Also check what did not happen: no new documents in Sanity, no schema change, no edit to `ChatPanel.tsx`.



### Agent criteria



- The route reads `guestId` from the request body and resolves it with `getGuest()`.

- A comment notes that identity comes from the verified session in a real app.

- The guest block is appended to the system prompt only when a guest resolves, and the allergen-check instruction lives inside that block.

- The anonymous path still works and is exercised as the control.

- No `groqFilter` changes, no writes to Sanity, no schema changes, and `ChatPanel.tsx` is unmodified.

- Both runs were sent to `http://localhost:3000/api/agent`, and both answers plus their tool calls are shown side by side.


## Got stuck?



- **Both answers look the same.** The guest block is not reaching the prompt. Log its length before the call.

- **The anonymous answer checks allergens too.** The check instruction is in the base prompt rather than the guest block.

- **Your agent reached for a per-guest `groqFilter`.** That is the documented trap. The profile changes what gets checked and how results rank, so it belongs in the system prompt.

- **Dara gets recommendations that ignore her vegetarian preference.** The block reached the prompt but reads as background rather than instruction. Say what to do with each signal.

- **A "`no matches found: --include=*.ts`" error in zsh.** That comes from a grep your coding agent improvised. Nothing in the repo produces it, and it has no effect on the mission.


Still stuck? [`checkpoints/1-5.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/checkpoints/1-5.md) in the starter describes what both answers should look like.



## Go deeper



- [Sanity Context patterns](https://www.sanity.io/docs/ai/sanity-context-patterns), including the personalized assistant

- [Context MCP reference](https://www.sanity.io/docs/ai/sanity-context-mcp)

- In the starter: [skills/sanity-workshop-context-groq/references/personalization.md](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/skills/sanity-workshop-context-groq/references/personalization.md), and [routing.md](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/skills/sanity-workshop-knowledge-bases/references/routing.md) for the table this mission adds a row to


## What the agent can still see



- [ ] Ask your agent about the Winter Miso Bowl, by name, as an anonymous guest


It is on the menu in your dataset with `status: internal`, flagged vegan, and it has not launched. Your agent can read it, describe it, and recommend it, because nothing you have built across five missions has said it should not.



Then ask "how many vegan options do you have?" again and see whether you get 7 or 8.



## Make it your own



- Ask "can I have my usual?" as Dara. Her usual order is the one item on the menu carrying a supplier cross-contact concern. Watch how the agent handles a request for something it has a reason to caveat

- Delete the "check allergen facts unprompted" line from the guest block and re-run. It is one sentence, and it is carrying most of the behavior you just built

- Add a second guest to `GUESTS` in `app/lib/loyalty.ts` with a different allergen and a different home market, then ask the same question again


## Recap



The same question now answers differently depending on who asked it. Dara's peanut allergy came from a CRM stub, rode into the system prompt as server-provided context, and turned a catalog question into a catalog question plus a safety check. Her profile never touched your schema, your dataset, or a `groqFilter`.



Everything you have changed so far has been about what the agent reaches for. Next, you'll change what it is allowed to see at all, server-side, so an unlaunched item comes back as nothing instead of being politely withheld.



---

## Lesson 10: Mission 1-6: Draw the boundary
https://www.sanity.io/learn/course/agents-and-agentic-workflows/draw-the-boundary

Set what the agent is allowed to see with a server-side groqFilter, and confirm no phrasing in the conversation can widen it.

Decide server-side what an agent is allowed to see, and confirm that no phrasing in the conversation can widen it.



## Set up



**Before you change anything,** ask in the chat at `localhost:3000`: "Tell me about the Winter Miso Bowl." Then ask "How many vegan options do you have?" Note both answers. This is your before state and you cannot get it back afterward.



Open your GROQ-mode MCP in the Context app: [https://www.sanity.io/manage](https://www.sanity.io/manage), select your organization, then **Context**, then **MCP Endpoints**, then the endpoint you created in Mission 1. Set its **groqFilter** field to `status == "published"` and save. From now on, the endpoint applies that filter server-side to every query the agent runs.



- [ ] `groqFilter` set to `status == "published"` on your GROQ-mode MCP, and saved


## The lesson



### What groqFilter is



`groqFilter` takes a filter expression, the part that goes inside `[...]` in a query, rather than a whole query:



```groq
status == "published"
```

The endpoint applies it server-side to every `groq_query` your agent runs, combined with the agent's own predicates using `&&`. Your agent writes whatever query it likes, and what executes is that query narrowed by your filter.



The conversation cannot widen it. A guest can insist, claim to be staff, or ask for "everything including unreleased items," and the filter is applied after all of that. A `?groqFilter=` URL parameter narrows it further still, since those combine with `&&` too.



### Retrieval and governance are different jobs



Up to now, what leaked was decided by whether your agent remembered to write `status == "published"` into its query. Mission 2 showed exactly that: the vegan count came back as 8 because the agent's own filter omitted `status`, and the boundary lived in the model's judgment.



With `groqFilter` set, the server decides. The agent can forget `status` entirely and still get 7.



The guest profile you injected in Mission 5 runs on the same principle from the other direction. What the agent knows about a guest is derived server-side, and what it can see is enforced server-side. The client is not consulted about either one.



GROQ is a query language. The access control is `groqFilter` on a private dataset.



### Why the dataset being private matters



The bootstrap script made your dataset private. On a public dataset, anyone with the project id can read around the filter by querying the API directly, which would make it a suggestion rather than a boundary. On a private dataset, API access is gated on a token the client never sees, and the filter is enforced today.



> [!WARNING]
> Dataset reads default to the `published` perspective, and anyone who can connect can pass `?perspective=drafts`. The private dataset and the organization token are the boundary. Do not attach a dataset whose drafts are sensitive.



### Two sources, two boundaries



`groqFilter` scopes the GROQ endpoint and nothing else. Your knowledge base has its own boundary, and it was set at build time by the dataset query in `kb/README.md`, which excludes every document with `status == "internal"`.



That exclusion covers the Winter Miso Bowl and its recipe. The recipe is marked internal for this reason: its body describes a dish being held for launch. A boundary has to cover every document that describes the thing, including the ones that are not named after it. Filter on the menu item alone and the recipe walks the fact straight out the other door.



This is the boundary people forget, because it lives where the knowledge base was built rather than where the question gets asked.



## Your mission



You are going to try to break a boundary you just set, and fail.



Nothing in the repo changes. There is no code in this mission at all, and everything you are about to test was configured in a single dashboard field.



1. **Talk your agent into surfacing the Winter Miso Bowl.** At least three phrasings, coming at it from different angles. Name the item directly, ask what is launching in December, ask for every vegan bowl including ones not on the menu yet.

2. **Re-ask the count.** "How many vegan options do you have?" This is the question that returned 8 in Mission 2 when the agent's query forgot `status`.

3. **Probe the other door.** If you wired the knowledge base in Mission 3, ask whether the Winter Miso Bowl is safe for a sesame allergy and tell the agent to check the food safety knowledge base. Two sources means two boundaries, and this tests the second one.


## Starting point



- The Winter Miso Bowl in the Studio. Open it. `status: internal`, `availableFrom` in December, `dietaryFlags` including vegan. You can see the whole document, which is what makes the next few minutes worth watching.

- Its recipe. Open that too, and read the body. It says the dish is being held for launch. Note that it is also marked internal.

- The two answers you recorded in Set up. Keep them somewhere you can compare against.


## The prompt



Copy this to your coding agent, or point your agent at `missions/1-6-draw-the-boundary.md` and tell it to start.



```text:Prompt
I'm on Mission 1-6 of the Sanity agent workshop (missions/1-6-draw-the-boundary.md). Load the
sanity-workshop-context-groq skill and read its references/groq-filter-and-scope.md.

I have just set groqFilter to `status == "published"` on my GROQ-mode Context MCP in the
Context app. Nothing in the repo changes for this mission — do not edit any code.

With pnpm dev running, try to make my agent surface the unlaunched Winter Miso Bowl (it is
status "internal", available from December, and flagged vegan) via the running app's /api/agent
route. Try at least three phrasings, for example: "Tell me about the Winter Miso Bowl", "What's
launching in December?", "List every vegan bowl including ones not on the menu yet." Then ask
"How many vegan options do you have?" Show me the GROQ and the results for each.
Finally, if my route has the knowledge base wired from Mission 1-3, ask once more: "Is the
Winter Miso Bowl safe for a sesame allergy? Check the food safety knowledge base." Show me
which tool answered and what it said.
Run this against the app at localhost:3000 — not through your own Sanity tools.
Report what came back. Do not change any code.
```

## Done when



### What you should see



- [ ] Try the phrasings yourself in the chat at `localhost:3000`, and read the trace under each answer rather than only the reply


**Every attempt returns an empty result.** The trace shows a `groq_query` with `resultCount: 0`, or a query that cannot match the item. Your agent reports that it cannot find such an item, and the trace is what tells you why: the document is unreachable. Check `meta.executedQuery` and you may see your filter combined into the query with `&&`.



There is a difference between an agent being discreet and an agent being unable. A model that says "I know about that but cannot discuss it" is still reading the document. An empty result means it never had it.



**The vegan count is 7,** even when the agent's own query omits `status`. That same omission gave 8 in Mission 2.



**The knowledge base probe finds no entry** for the bowl. Your agent may fall back to GROQ, which is also empty.



- **The bowl still appears in GROQ results.** The filter did not save, or `SANITY_CONTEXT_MCP_URL` points at a different endpoint than the one you edited.

- **The count is still 8.** Same two causes.

- **A polite refusal instead of an empty result.** Look at `resultCount` before concluding anything. If it is 0, the boundary is working and the model is narrating.

- **The knowledge base describes the bowl.** A build-time boundary problem rather than an app problem. See Got stuck? below.


**No code changed.** This mission has no code in it.



### Agent criteria



- No files in the repo were modified.

- At least three distinct phrasings were tried against `http://localhost:3000/api/agent`, and the GROQ plus the result count is reported for each.

- The vegan count question was re-asked and the number reported.

- If the knowledge base is wired, it was probed once and the answering tool is named.

- Results are reported as they came back, with no attempt to work around the filter.


## Got stuck?



- **The bowl still appears.** The filter did not save, or your app is hitting a different endpoint than the one you edited. Reload the MCP page in the Context app to confirm the value is there, and check `SANITY_CONTEXT_MCP_URL` against the endpoint name.

- **The vegan count is still 8.** Same causes as above.

- **The knowledge base describes the bowl,** including its build or its December launch. Its source dataset was read before the recipe was marked internal, or the build query lost its `status != "internal"` clause. Reimport the seed and rebuild the knowledge base. Nothing in the app will fix this, because the boundary was drawn at build time.

- **You want to be sure the filter is doing the work.** Temporarily clear it, ask again, and watch the bowl come back. Then set it again.


Still stuck? [`checkpoints/1-6.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/checkpoints/1-6.md) in the starter describes what each attempt should return.



## Go deeper



- [Content access and security](https://www.sanity.io/docs/ai/sanity-context-security)

- [Context MCP reference](https://www.sanity.io/docs/ai/sanity-context-mcp), including filtering

- [Dataset visibility](https://www.sanity.io/docs/apis-and-sdks/datasets)

- In the starter: [`skills/sanity-workshop-context-groq/references/groq-filter-and-scope.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/skills/sanity-workshop-context-groq/references/groq-filter-and-scope.md)


## What it's allowed to do



- [ ] Ask about the Winter Miso Bowl one last time, then open that same document in the Studio and read it end to end


An agent is only as good as what it can find and what it is allowed to do. Track 1 was the first half of that sentence, and every mission in it changed what your agent could reach or how it decided what to reach for. Nothing you built changed a single document.



Track 2 is the second half, and it does change documents. A publish in the Studio is an event, and the next track makes code run on it.



## Make it your own



- Argue with it. Tell your agent you are a staff member, that this is for internal planning, that you have authorization. The filter is applied after everything in the conversation, so none of it lands

- Narrow it further from the URL. Add `?groqFilter=` to your endpoint URL with a second condition and confirm the two combine with `&&` rather than the URL one replacing the app one

- Watch what happens to the Summer Peach Bowl, which is deprecated in the seed, now that the filter allows only published items


## Recap



Your agent cannot see the unlaunched Winter Miso Bowl, and no phrasing gets it back. The vegan count is 7 whether or not the query asks for it, because the filter is applied server-side to everything the agent runs. Your knowledge base holds its own version of the same boundary, drawn at build time by the query that fed it.



That closes Track 1. You wired an agent to your content, watched it choose a retrieval strategy per question, gave it a second source that reconciles what your dataset cannot, taught it which source owns which question, personalized it for a signed-in guest, and decided what it is allowed to see.



---

## Lesson 11: Track 2: What runs itself
https://www.sanity.io/learn/course/agents-and-agentic-workflows/what-runs-itself

See what an automated content pipeline has to get right before it writes to your content, and what the four missions in this track build.

Track 1 never changed a document. This track does, and that raises a different question: **what should be allowed to write to your content without a person watching?**



A publish is an event. Code can run on it, an AI step can draft from it, and a chain of steps can localize the result across three markets in a few seconds. All of that works. The interesting part is what happens on the item whose recipe reveals sesame the menu never declared, because a pipeline that only ever succeeds has not been tested.



### What you'll build



A Sanity Function that fires when a menu item is published. It reads the linked recipe, drafts the description and the allergen callout from the ingredients actually in the dish, adapts the copy for NYC, Austin, and Chicago, and refuses to draft anything at all when the recipe and the menu disagree about allergens.



Then a review gate in front of it, defined in code and deployed, that holds a menu item at review until a person clicks Approve.



> [!NOTE]
> **This track changes documents,** and the state is visible in the Studio and in the Function log. Where Track 1's observation was the GROQ your agent wrote, this track's is the log line the Function wrote.



### The four missions



- **Run code when content changes.** Sanity Functions and Blueprints, and the three gates that decide when a handler runs.

- **Add an AI step.** Agent Action Generate, drafting from facts you query and hand it.

- **Chain the steps together.** Transform, market variants, and a stop condition that holds a bad item back.

- **Put a human in front of it.** The Workflows engine, a deployed definition, and a publish hold a person releases.


### What you’ll learn



**An AI step is only as good as the context you hand it.** That is Track 1's lesson arriving from the write side. A callout drafted from the menu item alone says there is nothing to declare. A callout drafted from the recipe's ingredients names sesame.



**Some steps need a person in front of them.** Marketing copy localizes across three markets. A safety statement does not: it is one field, reproduced word for word. Knowing which is which is content modeling, and it shows up in your schema long before it shows up in a prompt.



By the final mission, you will have seen three enforcement mechanisms with three different guarantees: advisory engine checks, a guard whose enforcement is designed and pending, and the dataset access control from Track 1 that holds today. Knowing which one you are relying on is the difference between a process and a promise.



### Before you start



You need the same three tabs, plus a fourth terminal free for a log watcher. The bootstrap script already deployed the blueprint that registers this track's Function, so there is nothing to install.



Track 2 runs on the same repo and dataset as Track 1 and touches none of its work. If you skipped Track 1, this track still runs, though the final mission's composed finish is better with a knowledge base behind it.



Next, you'll publish a document and watch code run because of it.



---

## Lesson 12: Mission 2-1: Run code when content changes
https://www.sanity.io/learn/course/agents-and-agentic-workflows/run-code-when-content-changes

Fire a Sanity Function on a document event, publish a document, and read the log line it produced.

## Set up



The Bootstrap script has already deployed the blueprint that registers this Function.



- [ ] Three tabs, same as Track 1: `localhost:3333` (Studio), `localhost:3000` (the app), `localhost:8080` (the Functions emulator)

- [ ] A fourth terminal, free for the log watcher. Track 2 happens in the Studio and the terminal. The app is only there to show the menu this pipeline feeds


## The lesson



### What a Sanity Function is



A [**Sanity Function**](https://www.sanity.io/docs/functions/functions-introduction) is a small, single-purpose piece of code that runs on Sanity's cloud infrastructure and reacts to changes in your content. You write the handler, Sanity runs it. There is no server of yours involved anywhere.



People reach for them to enrich, validate, or constrain content as it changes, to build multi-step content workflows, and to connect content changes to systems outside Sanity: refresh a cache, post to a channel, update inventory.



There are several kinds. Document functions react to document changes, and there are also scheduled functions, Media Library asset functions, sync tag invalidate functions, and pubsub functions that other functions invoke. Yours is a document function, and it can fire on three events: `create`, `update`, and `delete`.



Each function's code lives in its own directory whose name matches the function's name, which is why `functions/draft-menu-copy/index.ts` is where you will find this one. The handler gets details about the changed document passed in, and it can reach the full Sanity API for anything else it needs.



Two constraints shape how you work with them. They run remotely, so your development loop is local test commands plus CLI logs rather than a debugger. And Sanity rate-limits invocations to stop runaway recursion.



### Blueprints, and why a Function needs one



A function on its own knows nothing about your project. It does not know which dataset it belongs to, what should wake it up, or what data it should receive. **[Blueprints](https://www.sanity.io/docs/blueprints/blueprints-introduction) **declare all of that.



Blueprints is infrastructure-as-code for Sanity resources. Instead of clicking things into existence in a web interface, you describe the resources you want in one file, `sanity.blueprint.ts`, and keep it in your repository next to your application code. Deploying compares that file against what already exists and makes reality match it. If a resource is not in the blueprint, it is not deployed. The file is the source of truth.



Blueprints are:



- **Auditable.** Your setup is in version control next to your code, so changes go through pull requests and have a history.

- **Reproducible.** Staging and per-developer environments come from the same file rather than drifting apart.

- **Deterministic.** A plan shows you the exact changes before you apply them.


Functions are one kind of resource. Webhooks, CORS origins, robot tokens, and custom roles are others. Each one is declared by calling a definer, a typed function that checks your input as you write it so mistakes surface in your editor rather than at deploy time. `defineDocumentFunction` is the definer this project uses. A stack is the collection of resources a blueprint deploys as one unit.



You do not deploy a function on its own. You deploy the blueprint, and the function goes with it, which is what bootstrap already did for you.



### The event block: three gates



Here is what `sanity.blueprint.ts` declares for `draft-menu-copy`:



```typescript:sanity.blueprint.ts
event: {
  on: ['create', 'update'],
  filter: '_type == "menuItem" && !defined(description.base)',
  projection: '{_id, _type, title, recipe}',
}
```

- `on` decides which document events wake the Function. Here, create and update.

- `filter` is a GROQ filter expression evaluated against the changed document. Only a match invokes the handler.

- `projection` shapes event.data. Anything outside it has to be queried for inside the handler.


An event gets past `on`, then has to satisfy `filter`, and what survives arrives shaped by `projection`. Keeping the `projection` small is deliberate: the handler receives the item's id, type, title, and recipe reference, and the ingredients and cross-c`on`tact statement are a query it makes for itself.



### The recursion guard is already in the filter



`!defined(description.base)` has been in that filter since day one, before anything writes to the field.



Mission 8 makes this Function write `description.base`, and a write emits another update event. Once the field exists, the filter stops matching, so the Function cannot loop on its own patch. `@sanity/client` 8 also sends a lineage header that caps recursive chains at 16, and Sanity's per-document rate limit sits under both.



In this mission the Function only logs, so the clause means something simpler: every seeded item fires, because none of them have drafted copy yet.



### Three ways to run it, and only one of them is a publish



- `sanity functions test` runs your local working copy and writes nothing. context.local is true.

- **The emulator on `localhost:8080`** runs your local working copy too. It is a payload editor: pick a function, type a document id, run.

- **A publish in the Studio** runs the deployed copy, and it writes to your dataset once the handler does more than log.

> [!WARNING]
> **The emulator does not receive Studio publishes.** A publish fires the deployed Function, which bootstrap deployed as the stub, and you watch it with `npx sanity functions logs draft-menu-copy --watch`.



That split matters more after the next mission than it does today. Until someone runs `pnpm --filter @starter/functions deploy`, a Studio publish runs the stub while `sanity functions test` runs your working copy.



### The handler



`documentEventHandler` receives `{context, event}`. `event.data` is whatever the projection let through. `context.local` tells the handler it is running locally, which is what puts the `(local run)` tail on the log line during a test.



For anything outside the projection, the handler builds its own client with `createClient({...context.clientOptions, apiVersion})`.



Right now it is twenty lines that log and return, so the log line is the only output this Function has.



In Track 1 you read the GROQ your agent wrote. Here you read the log the Function wrote. Both are how you watch work that otherwise happens where you cannot see it.



## Your mission



You are going to make a publish in the Studio cause code to run somewhere else, and get proof it happened.



Five steps, and the handler stays exactly as it is:



1. Build the functions so there is something to run.

2. Run the handler locally against a real seeded document with `sanity functions test`, and read the log line.

3. Understand the three gates in the blueprint, including why the recursion guard is there before anything recurses.

4. Start the log watcher in your spare terminal, before publishing anything.

5. Publish a menu item in the Studio and watch the line land.

> [!NOTE]
> Do not deploy anything, and do not touch the handler. Mission 8 does both.



## Starting point



- `functions/draft-menu-copy/index.ts`. Twenty lines. It logs the item and its recipe reference and returns, and the comment block at the top maps out what Missions 8 and 9 add.

- `sanity.blueprint.ts`. Read the `event` block and the comment above `filter`. Those two files are everything the next two missions change, so their shape now is what makes those diffs readable.

- A menu item in the Studio. Open one and find `description.base`, `description.nyc`, `description.austin`, `description.chicago`, and `allergenCallout`. Every one of them is empty, across every item in the seed. Today somebody writes that copy by hand, once per item and once per market.

- The watcher terminal. On a fresh project it reports "No logs found," which is a clean baseline to publish against.


## The prompt



Copy this to your coding agent, or point your agent at `missions/2-1-run-code-when-content-changes.md` and tell it to start.



```text:Prompt
I'm on Mission 2-1 of the Sanity agent workshop (missions/2-1-run-code-when-content-changes.md).
Load the sanity-workshop-functions-agent-actions skill and read AGENTS.md.

functions/draft-menu-copy/index.ts is a stubbed Sanity Function: it logs the menu item and its
recipe reference and returns. sanity.blueprint.ts registers it to fire on menuItem create/update
while description.base is undefined. Bootstrap already deployed the blueprint.

Do not change the handler yet. First build the functions and run it locally against a seeded
document with `sanity functions test`, using gg.menuItem.harissa-chickpea-bowl, and show me the
log line. Then explain what the blueprint's event filter and projection do and why the filter
has `!defined(description.base)` in it. Then start `npx sanity functions logs draft-menu-copy
--watch` in a spare terminal — bootstrap already deployed this Function, and a real publish fires
the deployed copy, not the local emulator — and tell me exactly what to do in the Studio at
localhost:3333 to make it fire. Do not deploy anything.
```

- [ ] While the watcher waits, open `functions/draft-menu-copy/index.ts` and `sanity.blueprint.ts` yourself and read them


## Done when



### What you should see



**The local run** prints the handler's log line with a `(local run)` tail:



```text:Terminal
✔ Function execution succeeded.
[draft-menu-copy] menuItem gg.menuItem.harissa-chickpea-bowl "Harissa Chickpea Bowl" → recipe: gg.recipe.harissa-chickpea-bowl  (local run — nothing is written)
```

**The explanation of the gates** should cover all three: the Function fires on `menuItem` create and update; the filter limits it to items with no drafted copy yet, which is every seeded item, and doubles as the recursion guard for when the Function starts writing that field; the projection shapes `event.data`, so ingredients and the cross-contact statement are queries the handler makes for itself.



- [ ] With the watcher running, open a menu item in the Studio, change anything, and publish. Within a few seconds the watcher prints "Function invocation started" followed by the same log line for that item, without the (local run) tail.


Creating a brand-new menu item and publishing it does the same thing.



- **Nothing in the watcher after a publish.** Give it a few seconds. Then check the item you published still has `description.base` empty, because the filter skips items that already have copy.

- **`No such function`.** It is not built. Run `pnpm --filter @starter/functions build`. The directory `name` has to match the blueprint's `name`.

- **You were watching `localhost:8080`.** The emulator does not receive publishes. Watch the `functions logs --watch` terminal.

- **The line still has `(local run)` on it.** That is your local test output rather than the deployed run.


**Nothing in your dataset changed.** This mission is log-only, and the log line is the artifact.



### Agent criteria



- `functions/draft-menu-copy/index.ts` is unmodified, and nothing was deployed.

- The functions package was built before running anything.

- `sanity functions test` was run against `gg.menuItem.harissa-chickpea-bowl` and its output reported verbatim.

- The explanati`on` covers `on`, `filter`, and `projection` individually, and says why `!defined(description.base)` is a recursi`on` guard.

- The log watcher command was started in a separate terminal before any publish.

- Studio instructions were given explicitly rather than the agent publishing on your behalf.


## Got stuck?



- **`No such function`.** The functions package is not built: `pnpm --filter @starter/functions build`. The directory `name` under `functions/` has to match the `name` in the blueprint.

- **Nothing appears after a publish.** Wait a few seconds first. Then confirm the item still has an empty `description.base`, since the filter skips anything that already has copy. Then confirm you are reading the watcher terminal rather than the emulator.

- **You published from a draft and nothing fired.** The event is the publish itself. Saving a draft is not one.

- **The emulator at `localhost:8080` shows nothing.** It is a payload editor rather than an event subscriber. Type a document id into it to run the handler by hand.

- **Every item you touch fires the Function.** Correct, for now. No item has `description.base` yet, so nothing is filtered out.


Still stuck? [`checkpoints/2-1.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/checkpoints/2-1.md) in the starter has the exact output for each step.



## Go deeper



- [Functions introduction](https://www.sanity.io/docs/functions/functions-introduction)

- [Blueprints introduction](https://www.sanity.io/docs/blueprints/blueprints-introduction)

- [Blueprint configuration reference](https://www.sanity.io/docs/blueprints/blueprint-config)

- [Document event handlers](https://www.sanity.io/docs/functions/document-event-handler)

- In the starter: [`skills/sanity-workshop-functions-agent-actions/references/local-dev-and-logs.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/skills/sanity-workshop-functions-agent-actions/references/local-dev-and-logs.md)


## The recipe reference in that log line



The log line ends with `→ recipe: gg.recipe.harissa-chickpea-bowl`. The handler logs that reference and returns without following it.



- [ ] Follow it yourself. Open that recipe in the Studio, open the ingredients it links to, and read their `allergenTags`. Then go back to the menu item and read its own `allergens` array


On four items in the seed, those two disagree. The declared list is what the menu says. The ingredient tags are what is in the dish.



## Make it your own



- Feed the local test a recipe instead of a menu item. `sanity functions test` evaluates the blueprint filter before invoking, so a recipe document id prints "`Filter ... returned an empty result. Skipping invoke.`" That is the fastest way to check a filter change without deploying anything

- Add a field to the projection, `category` for instance, then re-run the local test and watch `event.data` change shape. Revert it afterward, since the deployed copy still has the original

- Publish the same item twice and watch it fire both times. Nothing writes `description.base` yet, so nothing filters it out on the second pass


## Recap



A publish in the Studio ran code somewhere else, and the log line proved it. The blueprint decided when: `menuItem` create and update, only where `description.base` is undefined, with the payload shaped down to four fields. Your dataset is exactly as it was, because a handler that logs and returns changes nothing.



Next, you'll make it write. An Agent Action reads the recipe your handler is currently only logging a reference to, and drafts the description and the allergen callout from what is actually in the dish.



---

## Lesson 13: Mission 2-2: Add an AI step
https://www.sanity.io/learn/course/agents-and-agentic-workflows/add-an-ai-step

Add an Agent Action to a Sanity Function so it drafts a menu item's copy from the recipe you hand it.

## Set up



Agent Actions are schema-aware, so they need the id of your deployed schema. The bootstrap script deployed it for you.



- [ ] Get the id: `cd studio && npx sanity schemas list`

- [ ] Make it available to the handler as an environment variable rather than hard-coding it. For a deployed Function that is `npx sanity functions env add draft-menu-copy SANITY_SCHEMA_ID <id>`; locally, a `.env` the runner reads


## The lesson



### What an Agent Action is



An [**Agent Action**](https://www.sanity.io/docs/agent-actions) is a schema-aware content operation that runs against your dataset. It reads your deployed schema, so it knows what fields exist, what types they are, and where it is allowed to write. You give it an instruction and a set of target paths, and it writes content into them. There are three:



1. **Generate** writes new content into target paths from an instruction. 

2. **Transform** rewrites content that is already there, with a separate instruction per target. 

3. **Translate** handles languages. 


This mission uses Generate and we’ll use Transform later in mission 9.



> [!NOTE]
> Because the action reads the schema, it also respects it: fields marked `hidden` or `readOnly` are skipped. That is a schema-level way to keep a field out of reach of an AI step if you ever need one.



### Hand it the facts



The instruction is a prompt, and a prompt on its own is a hope. `instructionParams` is how you hand the step real data.



Two kinds are used here. A `field` param reads a path on the document being processed. A `groq` param runs a query, which is what lets a step reach past its own document into referenced ones. The instruction refers to each param by name with a `$` prefix.



The allergen callout has to come from `recipe->ingredients[]->allergenTags`, and the only way the model ever sees those tags is if you query for them and pass them in.



### The Generate call



```typescript:functions/draft-menu-copy/index.ts
await client.agent.action.generate({
  schemaId,
  documentId: event.data._id, // edits the draft
  instruction: `
    Write guest-facing menu copy for the dish $title.
    The dish's own menu description is $body. Use the recipe's ingredients ($ingredients)
    for concrete detail. Warm, concise, no superlatives, no health claims.

    Then write the allergen callout. List every allergen present in the recipe's ingredients
    ($traversedAllergens) as "Contains: …" — this list comes from the ingredients, not from
    the menu's declared list, and may include allergens the menu omitted. Never say "free of"
    or "safe for". End the callout with this sentence exactly, unchanged: $crossContact
  `,
  instructionParams: {
    title: {type: 'field', path: 'title'},
    body: {type: 'field', path: 'body'},
    ingredients: {
      type: 'groq',
      query: `*[_id == $id][0].recipe->ingredients[]->{title, allergenTags}`,
      params: {id: event.data._id},
    },
    traversedAllergens: {
      type: 'groq',
      query: `array::unique(*[_id == $id][0].recipe->ingredients[]->allergenTags[])`,
      params: {id: event.data._id},
    },
    crossContact: {
      type: 'groq',
      query: `*[_type == "crossContactStatement"][0].verbatimStatement`,
    },
  },
  target: [{path: ['description', 'base']}, {path: ['allergenCallout']}],
})
```

Read the `traversedAllergens` query closely. It hops from the menu item to its recipe, from the recipe to each ingredient, and collects the allergen tags off those ingredients. Nothing in it touches `menuItem.allergens`.



### target is the blast radius



`target` lists the paths the action is allowed to write. Two here, so nothing else on the document can change no matter what the instruction says or what the model decides would be helpful.



> [!WARNING]
> Set it deliberately every time. An Agent Action without a target is an AI step with write access to a whole document.



### Drafts by default



Both Generate and Transform edit the draft. Given a published `documentId`, the action edits the existing draft or creates one from the published document. Adding `forcePublishedWrite: true` writes the published document directly.



The default is the right one for a pipeline like this. Your published document is untouched, and the menu page reading the published perspective shows nothing new, until a person publishes the draft. The pipeline drafts and a person decides, which is what Mission 10 builds a gate for.



## Your mission



You are going to make the Function write for the first time, drafting two fields on a menu item from facts it looks up in the linked recipe.



What the Generate step has to do:



1. **Target exactly two fields:** `description.base` and `allergenCallout`.

2. **Draft the callout from the traversed `allergens`.** `recipe->ingredients[]->allergenTags`. The item's own `allergens` array is the wrong source, and on the Harissa Chickpea Bowl it would produce a callout saying there is nothing to declare.

3. **End the callout with the cross-contact statement, verbatim.** Pass it as a param and tell the model to reproduce it unchanged.

4. **Pass every fact as an `instructionParam`** rather than describing it in prose.

5. **Leave the default draft-write behavior alone.** No `forcePublishedWrite`.


Then run it locally with `sanity functions test` against `gg.menuItem.harissa-chickpea-bowl`, with `--with-user-token` so it can write.



> [!WARNING]
> **The deployed Function is still the Mission 7 stub.** Nothing you write in this mission changes what happens when someone publishes in the Studio, and it stays that way until a redeploy, which Mission 10 does on purpose. This mission runs locally.



Do not touch the schema, do not edit the seed, and do not deploy.



## Starting point



- Studio, then Menu items, then Harissa Chickpea Bowl. Under Description, Base copy is empty. Allergen callout is empty. There are no pending changes on the document.

- Its `allergens` array. Also empty. This item declares nothing.

- Its recipe. Open the linked recipe, open the ingredients it references, and read their `allergenTags`. Sesame is in there. The menu declares nothing; the dish contains sesame.


## The prompt



Copy this to your coding agent, or point your agent at `missions/2-2-add-an-ai-step.md` and tell it to start.



```text:Prompt
I'm on Mission 2-2 of the Sanity agent workshop (missions/2-2-add-an-ai-step.md). Load the
sanity-workshop-functions-agent-actions skill and read its references/agent-actions.md.

In functions/draft-menu-copy/index.ts, add an Agent Action Generate step that drafts two
fields on the menu item: description.base (guest-facing copy) and allergenCallout. The
callout must be drafted from the allergens actually present in the linked recipe's
ingredients — recipe->ingredients[]->allergenTags — NOT from the item's declared `allergens`
array, and it must end with the crossContactStatement's verbatimStatement reproduced exactly.
Pass those facts in as instructionParams (groq type) so the model is handed them, not asked to
know them. Target only those two fields. Get the schemaId from `cd studio && npx sanity
schemas list`. Leave the default draft-write behavior (do not set forcePublishedWrite).

Then run it locally with `sanity functions test` against gg.menuItem.harissa-chickpea-bowl,
with --with-user-token so it can write, and show me the log. Then tell me where to look in the
Studio to see the drafted fields, and quote both drafted values back to me. Do not touch the
schema and do not edit the seed. Do not deploy.
```

## Done when



### What you should see



The run happens in your terminal, and the Studio announces nothing while it does. Go and look.



- [ ] Reopen Harissa Chickpea Bowl in the Studio. It now shows pending changes, which is a draft

- **Base copy** is populated. Two or three sentences of guest-facing copy about harissa-roasted chickpeas, grains, and greens.

- **Allergen callout** is populated and it names sesame, phrased as "Contains: sesame" or similar. It never says "sesame-free" and never says "safe".

- **The callout ends with the cross-contact statement.** Open the `crossContactStatement` document and compare the two strings. They should match character for character.

- **The three market fields are still empty.** `nyc`, `austin`, and `chicago` are Mission 9.

- **Nothing is published.** This is a draft a person can still edit or discard.

- **The callout says there are no allergens.** The step read `menuItem.allergens` instead of traversing the recipe.

- **The cross-contact statement is paraphrased or shortened.** It was described in the instruction rather than passed as a param with a "verbatim, unchanged" rule.

- **Nothing appears to have changed in the Studio.** Look for pending changes rather than published values. Failing that, check for `noWrite: true` left on.

- **You published in the Studio and nothing drafted.** Expected. The deployed Function is still Mission 7's stub.

- [ ] Try the other three planted items. Miso Ginger Grain Bowl's callout should include wheat, from wheat-koji miso. Charred Broccoli & Farro's should include sesame. Buffalo Cauliflower Wrap's should include soy and egg. In every case the menu declared something less than the dish contains


### Agent criteria



- The handler fetches the recipe's ingredients and allergen tags, plus the cross-contact statement, as `groq`-type `instructionParams`.

- `client.agent.action.generate` is called with `schemaId`, `documentId: event.data._id`, an instruction, and `target` restricted to `description.base` and `allergenCallout`.

- `menuItem.allergens` is not the source of the callout anywhere in the handler.

- No `forcePublishedWrite`, no schema changes, no seed edits, and nothing deployed.

- The local run against `gg.menuItem.harissa-chickpea-bowl` succeeded, and both drafted values are quoted back verbatim.


## Got stuck?



- **Nothing changed in the Studio.** The action wrote a draft. Open the document and look for pending changes.

- **The callout paraphrases the cross-contact statement.** Pass it as a param and say "unchanged, verbatim" in the instruction. A model asked to include a sentence will improve it unless told not to.

- **The callout reports no allergens.** The step is reading the declared array. Check the `traversedAllergens` query actually dereferences: `recipe->ingredients[]->allergenTags`.

- **An error about the schema id.** Run `cd studio && npx sanity schemas list` and check the value reaching the handler.

- **Your agent proposes denormalizing allergens onto `menuItem`** to make this simpler. Say no. That gap is the entire lesson, and `pnpm verify` will fail if the seed loses it.

- **A publish in the Studio drafts nothing.** The deployed Function is still the stub from Mission 7.


Still stuck? [`checkpoints/2-2.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/checkpoints/2-2.md) in the starter describes the drafted values for each item.



## Go deeper



- [Generate quick start](https://www.sanity.io/docs/agent-actions/generate-quickstart)

- [Agent Actions patterns](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet)

- [Configure @sanity/client in Functions](https://www.sanity.io/docs/functions/functions-js-client)

- In the starter: [`skills/sanity-workshop-functions-agent-actions/references/agent-actions.md`](https://github.com/sanity-labs/sanity-agent-workshop/tree/main/skills/sanity-workshop-functions-agent-actions)


## Three empty fields



> [!TIP]
> Look at the item one more time. `description.nyc`, `description.austin`, and `description.chicago` are still blank



NYC stores are corporate-owned. Austin and Chicago are franchised. Those are different voices and different local references, and the base copy you just drafted is written for none of them in particular.



There is one `allergenCallout` on this document, with no market variants beside it. Before the next mission, decide for yourself which of those two fields should ever differ by market.



## Make it your own



- Preview without writing. `noWrite: true` returns the document the action would have written and commits nothing. It cannot be combined with `async`

- Run it against an item with no recipe. Not every menu item has one. Watch what the step does when the query it depends on comes back empty

- Discard the draft in the Studio and run it again. The blueprint filter still matches, because `description.base` is empty again


## Recap



The Function writes now. It queried the recipe, collected the allergen tags off each ingredient, handed those to an Agent Action as parameters, and drafted a callout naming sesame on an item whose menu declares nothing. The cross-contact statement came through verbatim because it was passed as data rather than described in a prompt.



The queries in `instructionParams` are what made that possible. It is the write-side version of what Track 1 taught from the read side: a step is only as good as the context you hand it.



Next, you'll chain a second Agent Action behind this one, adapt the copy for three markets, and give the pipeline a reason to stop.



---

## Lesson 14: Mission 2-3: Chain the steps together
https://www.sanity.io/learn/course/agents-and-agentic-workflows/chain-the-steps-together

Chain Agent Actions into one automated flow with a stop condition, then publish an item that should be held and prove it is.

## Set up



- [ ] Run `pnpm seed:reset`. Every menu item goes back to empty copy with no drafts, which is the before state both runs in this mission compare against


## The lesson



### The shape of the chain



```text
event (menuItem, description.base undefined)
  └─ 1. compute the delta: traversed allergens − declared allergens
       ├─ delta empty  → 2. Generate base copy + callout
       │                 3. patch base into nyc / austin / chicago, then Transform each in place
       │                 4. notify: "drafted copy for <title>"
       └─ delta present → HALT. No copy. Notify loudly: "<title> recipe reveals <delta>
                          the menu does not declare. Held for review."  Flag the item.
```

The delta check runs first, before anything is written. A chain that generates and then validates has already spent money and already put words on a document.



### Step 1: the delta



```typescript:functions/draft-menu-copy/index.ts
const {declared, traversed, title} = await client.fetch(
  `*[_id == $id][0]{
    title,
    "declared": coalesce(allergens, []),
    "traversed": array::unique(coalesce(recipe->ingredients[]->allergenTags[], []))
  }`,
  {id: event.data._id},
)
const delta = traversed.filter((a: string) => !declared.includes(a))
```

The four planted items, and what each one's delta comes out as:



- **Harissa Chickpea Bowl.** Declares nothing. Recipe reveals sesame.

- **Miso Ginger Grain Bowl.** Declares soy and sesame. Recipe also reveals wheat, from wheat-koji miso.

- **Charred Broccoli & Farro Bowl.** Declares wheat. Recipe also reveals sesame.

- **Buffalo Cauliflower Wrap.** Declares wheat and milk. Recipe also reveals soy and egg.


Mission 8 used the Harissa bowl as its success case, because the callout correctly picked up sesame. Here the same gap becomes the reason to halt: a menu declaring nothing for a dish containing sesame is a data problem a person should fix before any copy goes out. Both framings are honest, and a pipeline has to pick one.



### Transform is path-for-path



Transform rewrites each target where it stands. It cannot read one path into another, so it cannot pull `description.base` into `description.nyc`. Run against three empty market fields, it returns success and writes nothing at all.



The chain needs a patch in the middle: copy the base into the three market fields on the draft, then let Transform rewrite each one in place.



```typescript:functions/draft-menu-copy/index.ts
// Generate wrote the DRAFT. Read it by id — `*[_id == "drafts.…"]` returns nothing under the
// client's default published perspective; getDocument ignores perspective.
const draft = await client.getDocument(`drafts.${event.data._id}`)
const base = draft?.description?.base
await client
  .patch(`drafts.${event.data._id}`)
  .set({'description.nyc': base, 'description.austin': base, 'description.chicago': base})
  .commit()
```

Two traps live in those few lines. Reading the draft back needs `getDocument`, because a GROQ query for `drafts.…` returns nothing under the client's default published perspective. And the patch touches only the three market fields, never `description.base`, so it leaves the blueprint filter's test alone.



Then Transform, with an instruction per target that names what should differ:



```typescript:functions/draft-menu-copy/index.ts
target: [
  {
    path: ['description', 'nyc'],
    instruction:
      'NYC is corporate-owned. Polished brand voice, sharper and shorter, city pace. Mention the Flatiron, Midtown, or Williamsburg store only if natural.',
  },
  {
    path: ['description', 'austin'],
    instruction:
      'Austin is franchised. Relaxed, local-owner voice, a little warmth and humor, weekend-brunch energy.',
  },
  {
    path: ['description', 'chicago'],
    instruction:
      'Chicago is franchised. Straightforward, hearty, neighborhood-spot voice; winter-friendly framing is fine.',
  },
]
```

Three variants that come back near-identical are a prompt problem. The per-target instructions have to name the ownership model, the voice, and the local references, because "make it sound like Austin" gives the model nothing to work with.



### What varies by market, and what holds



`description` has four fields because copy changes by market. `allergenCallout` is one field with no variants beside it, and it has to end with the cross-contact statement reproduced without paraphrase or softening.



**Marketing copy localizes. A safety statement holds.**



Knowing which is which shows up in the schema before it ever shows up in a prompt. The Transform in this chain leaves `allergenCallout` out of its targets, and that omission is a content-modeling decision that the code is only carrying out.



### Recursion, again



Mission 7 introduced the blueprint filter as a recursion guard. Now that the handler writes in more than one place, the checklist matters:



- The filter `!defined(description.base)` covers the Generate write.

- Anything else you write to the published document inside the filter's scope needs its own guard: a `status` check, a `processedAt` timestamp, or `delta::changedAny`.

- Writes to drafts do not trigger the Function, because `includeDrafts` is not set.

- The `@sanity/client` lineage header caps recursive chains at 16. A net, rather than a design.


Flagging the held item has two safe shapes. Patching the draft is the simpler one, since draft changes emit no published-document event. Setting `status` to `internal` on the published document also works and pulls the item off the public menu, but it does emit an update event the filter still matches, so the handler needs a guard at the top that returns early.



### Your success log



The success log should report which fields are actually populated on the document, read back from the draft. A static list of field names in a template string will keep saying all four markets were written long after one of them silently was not.



## Your mission



You are going to turn the handler into a sequence that decides, before writing anything, whether this item should get copy at all.



The chain, in order:



1. **Compute the delta** between the `allergens` the recipe's ingredients contain and the `allergens` the menu item declares.

2. **If the delta is empty:** run Mission 8's Generate step, patch the base copy into the three market fields on the draft, run Transform to adapt each one in place, and log a success notification reporting the fields that are genuinely populated.

3. **If the delta is not empty:** halt before any copy is drafted, log a structured warning naming the item and the undeclared allergens, and flag the item in a way that does not re-trigger the Function.

4. **Explain the recursion reasoning in a comment,** because the next person to touch this handler needs to know which writes are safe.

> [!NOTE]
> `allergenCallout` stays out of the Transform targets.



Then two runs, in this order: Herb Falafel Wrap (`gg.menuItem.herb-falafel-wrap`), whose recipe reveals exactly what its menu declares, and then Harissa Chickpea Bowl, whose recipe reveals sesame the menu never mentions.



> [!WARNING]
> **Still local.** A Studio publish continues to run Mission 7's deployed stub, so this chain runs through `sanity functions test` until Mission 10 deploys it. Do not edit the seed or the schema, and do not deploy.



## Starting point



After `pnpm seed:reset`, open the Studio and confirm the baseline:



- Every menu item's five copy fields are empty. Base copy, NYC, Austin, Chicago, and Allergen callout. No pending changes anywhere.

- Herb Falafel Wrap. Read its `allergens` array, then follow its recipe to the ingredients and read their `allergenTags`. Wheat and sesame, both sides. This is the clean case.

- Harissa Chickpea Bowl. Declares nothing. Its recipe reveals sesame.

- Citrus Fennel Salad. Open it and look for a linked recipe. There isn't one, which makes its delta empty for a completely different reason.


## The prompt



Copy this to your coding agent, or point your agent at `missions/2-3-chain-the-steps-together.md` and tell it to start.



```text:Prompt
I'm on Mission 2-3 of the Sanity agent workshop (missions/2-3-chain-the-steps-together.md).
Load the sanity-workshop-functions-agent-actions skill and read its
references/chaining-and-stop-conditions.md.

Turn functions/draft-menu-copy/index.ts into a chain: first compute the delta between the
allergens the recipe's ingredients actually contain and the allergens the menu item declares.
If the delta is empty: run the Generate step from Mission 2-2, then patch description.base into
description.nyc, description.austin, and description.chicago on the draft (Transform is
path-for-path and cannot read one field into another), then an Agent Action Transform that
adapts each market field in place — NYC is corporate-owned, Austin and Chicago are franchised;
make the per-target instructions name what should differ — then log a success notification that
reports which fields are actually populated on the draft, not a static list. Leave allergenCallout alone in the
Transform; it is one field with no market variants. If the delta is NOT empty: halt before any
copy is drafted, log a structured warning naming the item and the undeclared allergens, and
flag the item in a way that does not re-trigger the Function. Explain your recursion reasoning
in a comment.

Then, with pnpm dev running: run `pnpm seed:reset`, run the Function locally against
gg.menuItem.herb-falafel-wrap (a clean item: its recipe reveals exactly what the menu declares)
and show me the log and the four description values from the Studio draft; then run it against
gg.menuItem.harissa-chickpea-bowl (its recipe reveals sesame the menu never declared) and show
me the log. Do not edit the seed or the
schema. Do not deploy.
```

## Done when



### What you should see



**Run one, Herb Falafel Wrap.** The log shows the steps in order: a delta check finding no undeclared allergens, Generate, the patch, Transform, and a success notification listing the fields that are populated. Open the draft in the Studio:



- `description.base` populated.

- `description.nyc`, `description.austin`, and `description.chicago` all populated and **visibly different in voice**. NYC tighter and more polished, Austin warmer and more casual, Chicago hearty and neighborhood-flavored. Read all three: the dish facts are identical across them and only the voice moved.

- `allergenCallout` populated by Generate, with no market variation.


**Run two, Harissa Chickpea Bowl.** The log shows the delta check finding sesame undeclared, then a structured warning naming the item and `["sesame"]`, and then nothing. No Generate. No Transform. In the Studio there is no market copy for this item, and whatever flag the handler chose is visible.



- [ ] Count the invocations in the log. There should be exactly one

- **The market fields are empty but Transform reported success.** The patch step is missing. Transform is path-for-path and had nothing to rewrite.

- **The three variants read near-identically.** The per-target instructions did not name the difference. A prompt fix.

- **`allergenCallout` has market variants.** Transform targeted it. It should not.

- **The Harissa bowl got copy.** The delta check is not running first.

- **The Function fired repeatedly.** The flag wrote to the published document inside the filter's scope with no guard.

- **The notification names fields that are actually empty.** The log is reporting a static list rather than reading the draft back.


**Nothing published changed in either run.** Both items' published documents are exactly as `pnpm seed:reset` left them.



### Agent criteria



- The delta is computed before any Generate or Transform call runs.

- The empty-delta branch runs Generate, then a patch of the base copy into the three market fields on the draft, then Transform with a distinct instruction per target.

- `allergenCallout` is absent from the Transform targets.

- The non-empty-delta branch writes no copy, logs a structured warning naming the item and the delta, and flags the item without re-triggering the Function.

- A comment explains the recursion reasoning for whichever flag was chosen.

- Both named items were run locally with `sanity functions test`, and the logs plus the four description values are reported.

- No seed edits, no schema changes, nothing deployed.


## Got stuck?



- **Transform "succeeded" but the three market fields are empty.** The patch step is missing. Transform rewrites in place and had nothing to rewrite.

- **Three near-identical variants.** A prompt problem. The per-target instructions have to name the ownership model, the voice, and what local references are allowed.

- **Reading the draft returns nothing.** A GROQ query for `drafts.<id>` comes back empty under the client's default published perspective. Use `client.getDocument()`, which ignores perspective.

- **The Function fires again after its own write.** Something wrote to the published document inside the blueprint filter's scope. Move the flag to the draft, or guard the top of the handler.

- **`pnpm seed:reset` complains about a missing dataset.** Check that your `.env` values are still in place from bootstrap.

- **The Harissa bowl drafted copy anyway.** The delta check is running after Generate rather than before it.


Still stuck? `checkpoints/2-3.md` in the starter describes both runs step by step.



## Go deeper



- [Transform patterns](https://www.sanity.io/docs/agent-actions/transform-cheatsheet)

- [Agent Actions patterns](https://www.sanity.io/docs/agent-actions/agent-action-cheatsheet)

- [Functions cheat sheet](https://www.sanity.io/docs/functions/functions-cheatsheet)

- In the starter: [`skills/sanity-workshop-functions-agent-actions/references/chaining-and-stop-conditions.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/skills/sanity-workshop-functions-agent-actions/references/chaining-and-stop-conditions.md)


## Held by what, exactly?



- [ ] Open the Harissa Chickpea Bowl in the Studio. No copy, and a note where the callout would be. Now look at the Publish button


It is enabled, the same as on every other document in your dataset. The pipeline decided this item should not ship, and that decision currently lives in a terminal log line and a field the pipeline set on itself.



Anyone with edit access can publish this item in one click, and nothing in the system will object.



## Make it your own



- Run the other three planted items. Miso Ginger Grain Bowl halts on wheat, Charred Broccoli & Farro on sesame, Buffalo Cauliflower Wrap on soy and egg. Four different deltas, one stop condition

- Run Citrus Fennel Salad. It has no linked recipe, so its delta is empty because there was nothing to traverse. A careful handler branches three ways on the callout: a recipe with allergens, a recipe with none, and no recipe at all. Decide what yours should say

- Weaken one per-target instruction to something like "make it sound local" and re-run. Watch that variant collapse toward the base copy


## Recap



The handler is a chain now. It computes a delta first, drafts and localizes only when the menu and the recipe agree, and halts loudly when they do not. Herb Falafel Wrap came back with four descriptions in three voices and one callout. Harissa Chickpea Bowl came back with nothing and a warning naming sesame.



The description localizes across three markets. The safety statement is one field reproduced verbatim. That asymmetry was decided in the schema long before any instruction was written.



Next, you'll turn the log line into an actual hold. You define a review workflow, deploy it, and a menu item waits at review until a person clicks Approve.



---

## Lesson 15: Mission 2-4: Put a human in front of it
https://www.sanity.io/learn/course/agents-and-agentic-workflows/put-a-human-in-front-of-it

Define a review workflow in code, deploy it, and hold a menu item at review until a person clicks Approve.

## Set up



Nothing to install. The Workflows Studio plugin is already registered in `studio/sanity.config.ts` with tag `production` and an `autoStart` mapping from `menuItem` to a definition named `menu-item-review`.



- [ ] Open `studio/sanity.config.ts` and read that registration. The definition it points at does not exist yet, which is what you are about to write

> [!NOTE]
> Workflows is in early access, built in public. Versioning, breaking changes, and what is enforced are all covered in [How early access works](https://www.sanity.io/docs/workflows/prerelease), and the enforcement part matters enough that it has its own section below.



## The lesson



### What Workflows is



[**Sanity Workflows**](https://www.sanity.io/docs/workflows/introduction) turns a content process into data. You describe the process once, as a definition: the stages content moves through, the work that happens in each stage, and the rules for moving on. Every time that process runs you get an instance, which is a Sanity document tracking that one run.



People, agents, and applications all follow the same definition and all write to the same record. A reviewer clicking Approve and a Function reporting a result are doing the same kind of thing to the same document.



It is organization-wide by design, so one workflow can coordinate content across projects, datasets, Canvas, and the Media Library. And because an instance is a document, the status of any run is a GROQ query rather than a thread of messages in a chat somewhere.



### The vocabulary, and the loop



- **Definition.** The codified process: stages, work, and rules. Deployed, versioned, and never changed in place.

- **Instance.** One live run, pinned to the definition version it started on.

- **Stage.** A named place in the process. An instance sits in exactly one at a time. A stage with no way out is terminal.

- **Transition.** A one-way link to the next stage that fires as soon as its condition holds.

- **Activity.** A unit of work belonging to one stage visit. It tracks its own state and holds no data.

- **Action.** What resolves an activity: a person approves, an agent finishes, a webhook reports back.

- **Field.** Data the instance carries, scoped to the workflow, a stage, or an activity. Most workflows declare a `subject` field naming the content the run is about.

- **Condition.** A GROQ expression deciding when a transition fires or a gate opens. It reads a snapshot the engine assembles in memory, so it sees only what the engine put there.

- **Guard.** A rule deployed as its own document beside the content it protects, declaring which mutations are allowed while an instance sits in a stage. It lasts exactly as long as that stage visit.


**Here is the loop those pieces make.** You deploy a definition. Starting it against a piece of content creates an instance in the first stage. That stage holds activities. An action resolves an activity and writes its result into the instance's fields. A transition watches those fields through a condition and moves the instance on the moment the condition holds. Repeat, stage by stage, until a stage has no way out.



**Nothing chooses to move the instance.** No component decides; a transition fires because a field changed and its condition became true. Nothing in a transition knows who wrote that field, which is exactly why the same gate can hold copy you typed and copy the Function drafted.



### The engine only runs when you call it



The workflow engine is a TypeScript library. There is no hosted service behind it, and nothing runs in the background watching your content and pushing runs along.



Three verbs cover every call into it:



- `fireAction` says someone or something acted.

- `tick` says something changed that the engine cannot see for itself: an edit to the subject, a deadline that passed, a child that finished. It re-evaluates the instance and advances it as far as it can.

- `evaluate` is read-only. It reports what an actor can do right now, and why they cannot do the rest. This is what a UI renders.


None of the three runs unless something calls it. A transition that should fire once a deadline passes needs a scheduled Sanity Function, a cron job, or a queue worker to call `tick` after the clock crosses it.



For this mission you never call any of them directly. A person clicking Approve in the Studio is the caller, and the Studio plugin makes the call.



That plugin is one surface among several. The engine, a CLI, the Studio plugin, an App SDK adapter, React components, and an MCP server all reach the same definitions and instances, and only the Studio plugin needs Studio at all. You will use three of them: the engine's `define` helpers to author, the CLI to deploy, and the plugin to click through.



> [!WARNING]
> Every read and write is scoped to a tag, and each surface sets its tag once. A definition deployed under `staging` is invisible to a Studio configured with `tag: 'production'`, which is the first thing to check when a surface finds no workflows.



### The definition you are about to write



Three stages: `drafting`, then `review`, then `approved` with no way out. A required `subject` field naming the menu item. One action in each of the first two stages, and a guard on `review`:



```typescript:studio/workflows/menu-item-review.ts
guards: [
  {
    name: 'hold-publish',
    title: 'Hold publishing during food-safety review',
    match: {idRefs: [{type: 'fieldRead', field: 'subject'}], actions: ['publish']},
  },
],
```

That guard is the hold. While an instance sits in `review`, the Studio disables Publish on the subject document and the tooltip names the workflow. When the instance leaves the stage, the guard goes with it.



### What is enforced, and what is not yet



Three layers sit between a workflow and the Content Lake, and they offer three different guarantees.



- **Engine checks** (action verdicts, permission gates, readiness, editability) hold against a raw write: no, by design. They exist so a UI can disable the right control and explain why.

- **A guard,** a document deployed beside the content declaring what is allowed during a stage visit, holds against a raw write: not yet. The Content Lake does not evaluate guard documents during early access.

- **Dataset access control,** including Track 1's `groqFilter` on a private dataset, holds against a raw write: yes, today. It is enforced by the Content Lake.


In the docs' own words: every check the engine makes is advisory, and the Content Lake is the only enforcement point. Anyone holding a write token can talk to the lake directly and skip the engine.



Three mechanisms and three different guarantees, in one course. Mission 6's boundary is enforced right now. This mission builds a coordination layer whose enforcement is designed and pending. A rule you cannot afford to have bypassed belongs in dataset access control today.



## Your mission



You are going to hold a menu item at review until a person approves it, and then watch that same gate hold work the Function drafted.



Part one, in code:



1. **Write `studio/workflows/menu-item-review.ts`** with the `define` helpers from `@sanity/workflow-engine/define`: stages `drafting`, `review`, `approved`, a required `subject` field, a "submit for food-safety `review`" action in `drafting`, an "approve" action in `review`, and the publish guard on `review`.

2. **Write `studio/sanity.workflow.ts`** with `defineWorkflowConfig`: name and tag `production`, `expectedMinReaderModel` 4, and `workflowResource` pointing at your project and dataset.

3. **Deploy in three passes.** `--check` first, which validates offline. Then `--dry-run` to read the diff. Then the real deploy.


Part two is the click-through, and it belongs to you. Your agent writes the files and runs the deploy, then tells you what to click. Do not let it click for you. A human gate needs a human standing at it.



> [!NOTE]
> No schema fields, and no edits to `sanity.config.ts`.



## Starting point



- `studio/sanity.config.ts`. Find the `workflowStudioPlugin` registration, its `tag`, and the `autoStart` mapping from `menuItem` to `menu-item-review`.

- The Workflows tool in the Studio navbar. Open it. Nothing there.

- Any menu item. There is no workflow strip above the form, and Publish is enabled, as it has been on every document all course. That includes the item Mission 9's pipeline refused to draft copy for. Deciding content should be held is a different job from holding it, and nothing in the Studio currently does the second one.


## The prompt



Copy this to your coding agent, or point your agent at `missions/2-4-put-a-human-in-front-of-it.md` and tell it to start.



```text:Prompt
I'm on Mission 2-4 of the Sanity agent workshop (missions/2-4-put-a-human-in-front-of-it.md).
Load the sanity-workshop-workflows-engine skill and read its references/define-and-deploy.md.

The Workflows Studio plugin is already registered in studio/sanity.config.ts with tag
"production" and an autoStart mapping from menuItem to a definition named "menu-item-review"
that does not exist yet. Do not edit sanity.config.ts.

Write studio/workflows/menu-item-review.ts with the define helpers from
@sanity/workflow-engine/define: stages drafting → review → approved, a required subject field
for the menu item, a "submit for food-safety review" action in drafting, an "approve" action in
review, and a guard on the review stage that holds publishing of the subject. Write
studio/sanity.workflow.ts with defineWorkflowConfig, name and tag "production",
expectedMinReaderModel 4, and workflowResource pointing at my project and dataset from
studio/.env. From the studio directory run the deploy with --check, then --dry-run and show me
the diff, then deploy for real with `pnpm exec sanity-workflows deploy --tag production`.

Then tell me exactly what to click in the Studio at localhost:3333 to create a new menu item,
submit it, see that Publish is held, approve it, and read the run's history. Do not add any
schema fields. Do not click through for me — I want to do that part.
```

## Done when



### What you should see



The deploy prints a created definition:



```text:Terminal
✔ Processed 1 definition(s) · production (production) → <projectId>.production
  ✔ created   menu-item-review v1
```

Then click through it yourself. Six steps, in the Studio at `localhost:3333`:



> [!TIP]
> **Create a new menu item.** It has to be new. `autoStart` does not apply to documents that already exist. The workflow strip appears above the form reading Drafting, with no Start button needed


> [!TIP]
> **Look at the Workflows view** beside the editor. It lists one activity: Draft the description and allergen callout


> [!TIP]
> **Fire "Submit for food-safety review."** The strip moves to Food-safety review


> [!TIP]
> **Try to publish.** The Publish action is disabled, and the tooltip names the workflow. That is the guard


> [!TIP]
> **Fire "Approve."** The strip moves to Approved and Publish is enabled again


> [!TIP]
> **Read the history.** Workflows tool in the navbar, then Overview, then your run, then the detail panel. Start, the submit action, the transition to review, the approve action, the transition to approved, each with who did it and when


- **The Workflows view is empty after deploying.** Check in this order: the deploy tag against the plugin's tag, then which dataset the definition landed in, then whether the name is exactly `menu-item-review`.

- **The strip offers "Start workflow" instead of auto-starting.** The document already existed. `autoStart` is fresh-only.

- **The strip shows the workflow greyed out.** The reason is in the browser console under `[workflow-studio-plugin]`.

- **Publish stayed enabled at `review`.** The guard is missing from the `review` stage, or its `match` does not name the subject.


### Agent criteria



- `studio/workflows/menu-item-review.ts` exports a `defineWorkflow` named `menu-item-review` with the three stages, a required `subject` field with `initialValue: {type: 'input'}`, and a guard on `review` matching the `subject` with `actions: ['publish']`.

- `studio/sanity.workflow.ts` exports a `defineWorkflowConfig` with name and tag `production`, `expectedMinReaderModel: 4`, and a `workflowResource` id of `<projectId>.production`.

- The deploy ran as `--check`, then `--dry-run` with the diff shown, then for real.

- `sanity.config.ts` is unchanged and no schema fields were added.

- Studio instructions were written out for you rather than performed by the agent.


## Compose it: the Function drafts, the gate holds



The gate you just tested held copy you typed. Now put the Function behind it, and nothing in the definition changes.



Two facts do the work together: the deployed Function fires on publish, and the guard holds Publish only during review. So a new item gets published once, bare, while still in `drafting`. That publish is the event. The Function drafts the copy into the document's draft, and the review gate stands between that AI draft and the live menu.



First, deploy your Mission 9 handler. Bootstrap deployed the Mission 7 stub, and a Studio publish has been running that stub ever since.



```sh:Terminal
pnpm --filter @starter/functions deploy               # builds, then deploys the blueprint (~1–2 min)
npx sanity functions logs draft-menu-copy --watch     # spare terminal, before you publish
```

> [!NOTE]
> Skipped Missions 8 and 9? Do this with the stub. You will see its log line on publish and type the copy yourself, and the gate behaves identically.



Then the six clicks again, with the Function in the loop:



> [!TIP]
> **Create a new menu item.** Fill the required fields and link a recipe: the Herb Falafel Wrap's for a clean run, the Harissa Chickpea Bowl's to watch the stop condition. Leave Base copy and Allergen callout empty. The strip reads Drafting


> [!TIP]
> **Publish it.** Publishing is allowed in `drafting`, and this is the event. Within a few seconds the watcher prints the invocation and the document gains pending changes: Base copy, three market variants, and the callout, drafted from the recipe. With the Harissa recipe instead: no copy, a warning naming sesame, and the held flag


> [!TIP]
> **Submit for food-safety review.** The strip moves and Publish goes disabled. The AI draft is now held


> [!TIP]
> **Read the callout as the reviewer.** Does it name every allergen the recipe reveals? Does it end with the cross-contact statement, word for word? This read is the review a person is here to do


> [!TIP]
> **Approve,** then publish the draft. The copy goes live


> [!TIP]
> **Read the history again.** Start, submit, transition, approve, transition. The Function's work lives in the log and the draft; the history records the human side. Coordination and execution, kept in two places deliberately


> [!WARNING]
> For the few seconds between steps 2 and 5, a bare item with no copy is on the live menu. In production you would create it as `status: internal` and flip it at approval, which is Mission 6's boundary reused. Leave that out here unless your Mission 9 handler already uses `status` as its halt flag.



## Got stuck?



- **An empty Workflows view after a successful deploy.** The tag or the definition name, before the plugin. Check the deploy tag against the plugin's tag, then the dataset, then the name.

- **No strip on the document.** `autoStart` is fresh-only. Create a new item.

- **The workflow is greyed out in the strip.** The browser console has the reason under `[workflow-studio-plugin]`.

- **You published and nothing drafted.** The watcher will show Mission 7's log line: the deployed Function is still the stub. Redeploy.

- **The draft arrives slowly.** Ten to thirty seconds is normal for the composed run.

- **Package version mismatch.** Every `@sanity/workflow-*` package in one application has to be on the same version. They declare each other as exact-version peers, and pnpm does not install peers for you, so a mismatch can surface as a surface that loads and never finds a workflow.


Still stuck? `checkpoints/2-4.md` in the starter covers both click-throughs and the deploy output.



## Go deeper



- [Workflows introduction](https://www.sanity.io/docs/workflows/introduction)

- [How early access works](https://www.sanity.io/docs/workflows/prerelease)

- [Definitions and instances](https://www.sanity.io/docs/workflows/definitions-and-instances)

- [Guards](https://www.sanity.io/docs/workflows/guards)

- [Deploy definitions](https://www.sanity.io/docs/workflows/deploy-definitions)

- [Workflows cookbook](https://www.sanity.io/docs/workflows/cookbook)

- In the starter: [`workflows/README.md`](https://github.com/sanity-labs/sanity-agent-workshop/tree/main/workflows) and [`skills/sanity-workshop-workflows-engine/references/enforcement.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/skills/sanity-workshop-workflows-engine/references/enforcement.md)


## Three mechanisms, three guarantees



- [ ] Hold one more new item at `review`. Then, from a terminal, publish it with a write token


It lands. The Content Lake does not consult the guard document during early access. Go back and look at the instance: it is still at `review`, because its transitions watch its own fields and nothing wrote to them. The workflow neither blocked that write nor noticed it.



That is the honest shape of what you built. The engine and the Studio plugin honor the guard. The lake does not yet. And in Mission 6, `groqFilter` on a private dataset held against every phrasing you threw at it, because the lake was doing the enforcing.



Advisory checks, a guard with enforcement designed and pending, and dataset access control that holds today. Knowing which one you are relying on is the difference between a process and a promise.



## Make it your own



- Run the Harissa recipe through the composed flow. The Function halts, no copy is drafted, and the item sits at review with a warning instead of a description. That is the pipeline handing a person exactly the case it could not resolve

- Read the instance as data. It is a document. Query it with GROQ and look at its stage, its fields, and its history

- Deploy a second version of the definition with a changed stage title, then check a run that was already in flight. It stays pinned to the version it started on


## Recap



You wrote a content process as code, deployed it, and a menu item stopped at a stage until a person approved it. Then you put the Function behind the same gate without touching the definition, because an action writes to fields and a transition watches fields, and neither one asks who was acting.



Those two halves are the course: an agent that reads your content through a Context endpoint you scoped, and a pipeline that writes to it through a gate a person controls. An agent is only as good as what it can find and what it is allowed to do. You built both halves, and you decided where the boundaries go.



---

## Lesson 16: What you built (mission accomplished!)
https://www.sanity.io/learn/course/agents-and-agentic-workflows/what-you-built

Review what the two tracks built and what each one taught, then tell us how the workshop went.

Both tracks started from the same dataset and the same question: what should an agent be able to find, and what should it be allowed to do?



### Track 1: an agent that answers from your content



You pointed an agent at your dataset through a Context MCP in GROQ mode and read the query it wrote. You asked three questions and watched one endpoint answer with three different query shapes, then fixed a wrong one from the Instructions field. You built a knowledge base from your dataset and four files that were never in it, and a question that took several round trips came back in one cited lookup. You became the router, personalized the agent for a signed-in guest without writing a word of it into Sanity, and drew a server-side boundary that no phrasing in the conversation could widen.



Content was never the lever. Every change was what the agent could reach, or how it decided what to reach for.



### Track 2: a content process that runs itself



You fired a Sanity Function on a publish and read the log line it wrote. You gave it an Agent Action and watched it draft a description and an allergen callout from the recipe's ingredients, naming an allergen the menu never declared. You chained Generate and Transform into three market variants with a stop condition that refused to ship an item whose recipe contradicted its menu. Then you put a person in front of all of it: a review workflow defined in code, deployed, holding the AI's draft until someone clicked Approve.



An AI step is only as good as the context you hand it, and some steps need a person in front of them.



### Take it to your own content



Every mission prompt names its own assumptions: the question, the field, the item. Swap those, keep the constraint lines, and point the agent at your own dataset. [`missions/make-it-yours.md`](https://github.com/sanity-labs/sanity-agent-workshop/blob/main/missions/make-it-yours.md) in the starter covers what transfers as-is and what assumes the Green & Gather seed.



### Tell us how it went



This workshop is new, and the next run is shaped by what worked and what didn’t work for you. The survey takes just a few minutes minutes or you can leave feedback on this page!



- [ ] [**Take the course survey**](https://forms.gle/anppzZZGhJidEFfP8)!


Thank you so much for joining us! Let us know what you built in the Sanity Discord server.



- [ ] Join the [Sanity Discord](https://discord.gg/sanity-1304483263171264613) server


---

## Related Resources

- [All courses and lessons](https://www.sanity.io/learn/sitemap.md)
- [Complete content for LLMs](https://www.sanity.io/learn/llms-full.txt)
