A Board Game agent built using Sanity Context and Vercel's AI SDK
An OpenAI Agent, a board game API, and Sanity Context walk into a bar… the result: a CLI agent that returns exact game recs with real-time data using queries

Jarod Reyes
Head of Developer Experience & Community at Sanity
Published
I love board games. They are a very good excuse to entice my kids away from their books and spend some time with the parents. Plus I think I read somewhere that they increase neuroplasticity and I could use plenty more of that. So I decided to use our nifty new Sanity Context feature to build an agent that recommends games based on my interests. In this tutorial I’ll touch on the key steps of the build so you can follow along or work with your favorite AI buddy to accomplish something similar.
If you’d like to follow along with the full code files checkout this repo:
git clone https://github.com/sanity-labs/boardgame-agent-cli
What we’re actually building
Before we dive in any further let’s see this thing in action:

I asked the agent, using GPT-4o, to recommend a new cooperative board game for my family that uses narrative story telling and city-building. When I tried this same query with OpenAI’s latest GPT 5.5 model it was not able to find me a board game made later than 2023. Instead my agent recommended a top-rated game from this year called Cozy Stickerville.
Notice that my agent didn’t guess. Behind the scenes it ran a GROQ query (GROQ is Sanity’s open-source query language) against our Content Lake and returned a game that was released in 2026, has both mechanics tagged in their records, directly from BoardGameGeek’s (BGG) API. Rad, let’s build it.
The recipe
By the end of this tutorial you’ll have:
- A Sanity project with a
boardGameschema, populated from BGG’s XML API - A configured Context plugin for Sanity Studio that scopes an AI agent to your board game data
- A rather robust agent module that answers natural-language questions by running real GROQ queries against your own Content Lake (this is where you should spend the most time customizing).
For this demo specifically I wanted to focus on the build patterns of building an agent with Context and show that agents can live in different interfaces - which means there is no frontend. It’s a CLI, ya’ dig?
Prerequisites
- Node.js 20+ — nodejs.org. Run
node --versionto confirm. - A Sanity account — free at sanity.io
You can create an account usingnpm create sanity@latestas shown below - A BoardGameGeek XML API token — registration is required. Create an application at boardgamegeek.com/applications, then create a token and send it as
Authorization: Bearer …on every API request. See Using the XML API. - An OpenAI API key — the agent script uses GPT-4o by default. You can swap in any GPT-4o by default. You can swap in any Vercel AI SDK provider.
- A deployed Sanity Studio — studio is where you configure Context for the agent.
Create a Sanity project
npm create sanity@latest -- --template clean --dataset production --project-name=bgg-agent-tutorial --output-path bgg-agent
Follow the prompts. When asked to install the MCP server, choose yes, it allows you and your agent to interact with Sanity’s docs and tools directly.
Expected output:
✔ Running pnpm install ✅ Success! Your Studio has been created. (cd ~/.../bgg-agent to navigate to your new project directory) Get started by running pnpm dev to launch your Studio's development server Other helpful commands: npx sanity docs browse to open the documentation in a browser npx sanity manage to open the project settings in a browser npx sanity help to explore the CLI manua
Your project ID will now be in will now be in sanity.config.ts. Keep it handy, we’ll need to add this to the .env file.
Or clone from GitHub: Clone the repo, run npm install, then run:
npx sanity init --env
This will walk you through logging in and selecting (or creating) a project, and automatically write your projectId and dataset to a .env file. Then copy any remaining variables from .env.example and update sanity.config.ts and sanity.cli.ts to match.
Define the board game schema
In Sanity, a schema is a TypeScript definition that describes the shape of your documents, what fields they have, what types those fields are, and how they're validated. It's the contract between your content and everything that reads it: Studio uses it to render the right editing form, your frontend uses it to know what to expect, and Context uses it to expose your data structure to the AI agent.
The default Studio template includes a placeholder schemaTypes/index.ts with a sample type. We're going to replace that with the actual boardGame document type and split it into its own file while we're at it, which is the convention for maintainable Sanity projects.
Create a new file at schemaTypes/documents/board-game.ts:
// schemaTypes/documents/board-game.ts
import {defineField, defineType, defineArrayMember} from 'sanity'
import {ControlsIcon} from '@sanity/icons'
export const boardGame = defineType({
name: 'boardGame',
title: 'Board Game',
type: 'document',
icon: ControlsIcon,
fields: [
defineField({
name: 'bggId',
title: 'BGG ID',
type: 'number',
validation: (rule) => rule.required(),
}),
defineField({
name: 'name',
title: 'Name',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({name: 'yearPublished', title: 'Year Published', type: 'number'}),
defineField({name: 'minPlayers', title: 'Min Players', type: 'number'}),
defineField({name: 'maxPlayers', title: 'Max Players', type: 'number'}),
defineField({name: 'minPlaytime', title: 'Min Playtime (min)', type: 'number'}),
defineField({name: 'maxPlaytime', title: 'Max Playtime (min)', type: 'number'}),
defineField({name: 'averageRating', title: 'BGG Average Rating', type: 'number'}),
defineField({name: 'weight', title: 'Complexity Weight (1–5)', type: 'number'}),
defineField({
name: 'categories',
title: 'Categories',
type: 'array',
of: [defineArrayMember({type: 'string'})],
}),
defineField({
name: 'mechanics',
title: 'Mechanics',
type: 'array',
of: [defineArrayMember({type: 'string'})],
}),
defineField({
name: 'designers',
title: 'Designers',
type: 'array',
of: [defineArrayMember({type: 'string'})],
}),
],
})Then update schemaTypes/index.ts to import from it:
import {boardGame} from './board-game'
export const schemaTypes = [boardGame]The mechanics and categories arrays are what make the GROQ queries genuinely useful later - they let the agent filter by structured tags rather than approximate text matching.
Next we need to deploy the schema to Content Lake so the Agent Context server knows your data shape, we’ll use the following sanity deploy command which has the added benefit of deploying our studio as well.
npx sanity deployPull board game data into Content Lake
Install the XML parsing package:
npm install fast-xml-parser
Create ingest.mjs at the project root. This is not the full file, but gives you the shape. You can see my version here: https://github.com/jarodreyes/boardgame-sanity-cli/blob/main/ingest.mjs
// ingest.mjs - Fetch top 50 games from BoardGameGeek using the XML API
import {getCliClient} from 'sanity/cli'
import {XMLParser} from 'fast-xml-parser'
// getCliClient reads projectId, dataset, and apiVersion from your sanity.config.ts
// automatically. Run this script with: sanity exec ingest.mjs --with-user-token
const client = getCliClient({useCdn: false})
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
isArray: (name) => ['item', 'name', 'link'].includes(name),
})
// add function to set Auth Headers for BGG API
// add function to batch request (20 IDs max per BGG call)
// add function to retry on fetch fail
...
// This is not the full code sample needed to handle ingesting BGG data.
// For the full file, which handles batching, auth, and
// a bunch of other specific nuances for the BGG API, view it at:
// https://github.com/jarodreyes/boardgame-sanity-cli/blob/main/ingest.mjsCreate a .env file at the project root. For Sanity, go to sanity.io/manage, open your project, click API → Tokens, and create one with Editor permissions. For BGG, use the bearer token from Applications → Tokens for your registered app.
SANITY_PROJECT_ID=your_project_id BGG_API_TOKEN=your_bgg_bearer_token
Run the ingestion:
sanity exec ingest.mjs --with-user-token
Why we use getCliClient()
`getCliClient()` picks up your projectId, dataset, and apiVersion directly from sanity.config.ts — no duplication. The `--with-user-tokenflag` passes your active sanity login session to the script, so you don't need a separate SANITY_API_TOKEN environment variable for local ingestion runs. You only need an API token when running in a non-interactive environment like CI.
Expected output:
Fetched 50 IDs from BGG hot list Fetching details for 58 games... Fetching game details batch 1/3 (20 games)... Fetching game details batch 2/3 (20 games)... Fetching game details batch 3/3 (18 games)... Imported 58 board games into Content Lake
BGG’s thing endpoint accepts at most 20 IDs per request; the script batches automatically and waits 2 seconds between batches.
Start the Studio (npm run dev, then open localhost:3333). After the default small ingest you should see on the order of ~60 board games; each with ratings, complexity weights, mechanics, categories, player counts, playtime ranges, and designer credits from BGG.
Install Sanity Context Sanity Context
npm install @sanity/agent-context
Open sanity.config.ts and add the plugin:
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {agentContextPlugin} from '@sanity/agent-context/studio'
import {schemaTypes} from './schemaTypes'
export default defineConfig({
name: 'default',
title: 'BGG Agent',
projectId: 'your-project-id',
dataset: 'production',
plugins: [structureTool(), agentContextPlugin()],
schema: {types: schemaTypes},
})Restart the Studio after the config change.
Create the Context document
In the Studio sidebar, you’ll see a new Context section. Click it, then Create new Agent Context. Fill in these fields:

*You may see this labeled Agent Context in your Studio dashboard. The product has been renamed to Sanity Context.
Save the document. The Studio generates an MCP URL (the API path includes a date version, e.g. v2026-04-09 — use exactly what Studio shows, not a guess):
https://api.sanity.io/vYYYY-MM-DD/agent-context/your-project-id/production/board-games
Copy it.
Connect the agent
Create agent.mjs at the project root:
// Requires: SANITY_CONTEXT_MCP_URL, SANITY_API_READ_TOKEN (Viewer), OPENAI_API_KEY
import 'dotenv/config'
import {randomUUID} from 'node:crypto'
import {generateText, stepCountIs} from 'ai'
import {createMCPClient} from '@ai-sdk/mcp'
import {openai} from '@ai-sdk/openai'
import {createClient} from '@sanity/client'
import {sanityInsightsIntegration} from './agent-insights-telemetry.mjs'
import boxen from 'boxen'
import chalk from 'chalk'
const ansiStdout = process.stdout.isTTY && !process.env.NO_COLOR
const ansiStderr = process.stderr.isTTY && !process.env.NO_COLOR
function errDim(msg) {
console.error(ansiStderr ? chalk.dim(msg) : msg)
}
function errStep(msg) {
console.error(ansiStderr ? chalk.cyan('›') + ' ' + chalk.dim(msg) : msg)
}
function printQuestion(q) {
if (ansiStderr) {
console.error(
boxen(q, {
title: chalk.bold.cyan('Question'),
titleAlignment: 'left',
padding: {top: 0, bottom: 0, left: 1, right: 1},
margin: {top: 0, bottom: 1},
borderStyle: 'round',
borderColor: 'cyan',
}),
)
} else {
console.error(`Question:\n${q}\n`)
}
}
/** Light Markdown → ANSI for typical model replies (bold, `code`, headers, links). */
function formatAssistantText(text) {
if (!ansiStdout) {
return text
.replace(/\*\*(.+?)\*\*/gs, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^#{1,3} /gm, '')
}
let t = text
t = t.replace(/^### (.+)$/gm, (_, h) => chalk.magenta.bold(`▸ ${h}`))
t = t.replace(/^## (.+)$/gm, (_, h) => chalk.magenta.bold(h))
t = t.replace(/^# (.+)$/gm, (_, h) => chalk.magenta.bold.underline(h))
t = t.replace(/\*\*(.+?)\*\*/gs, (_, x) => chalk.bold.whiteBright(x))
t = t.replace(/`([^`]+)`/g, (_, x) => chalk.green(x))
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) =>
chalk.blue.underline(label) + chalk.dim(` · ${url}`),
)
return t
}
const mcpUrl = process.env.SANITY_CONTEXT_MCP_URL?.trim()
const readToken = process.env.SANITY_API_READ_TOKEN?.trim()
if (!mcpUrl || !readToken) {
console.error(
ansiStderr
? chalk.red.bold('✖ ') +
chalk.red(
'Set SANITY_CONTEXT_MCP_URL and SANITY_API_READ_TOKEN in .env.\n' +
'Create an Agent Context in Studio and copy the MCP URL; use a Viewer token from sanity.io/manage.',
)
: 'Set SANITY_CONTEXT_MCP_URL and SANITY_API_READ_TOKEN in .env.\n' +
'Create an Agent Context in Studio and copy the MCP URL; use a Viewer token from sanity.io/manage.',
)
process.exit(1)
}
if (!process.env.OPENAI_API_KEY?.trim()) {
console.error(ansiStderr ? chalk.red.bold('✖ ') + chalk.red('Set OPENAI_API_KEY in .env.') : 'Set OPENAI_API_KEY in .env.')
process.exit(1)
}
/** AI SDK v6: `result.text` is only the *last* step; tool rounds often leave it empty. */
function textFromResult(result) {
const fromSteps = result.steps
.map((s) => (typeof s.text === 'string' ? s.text.trim() : ''))
.filter(Boolean)
.join('\n\n')
.trim()
if (fromSteps) return fromSteps
if (typeof result.text === 'string' && result.text.trim()) return result.text.trim()
return ''
}
const question =
process.argv.slice(2).join(' ').trim() || 'How many board games are in the database?'
printQuestion(question)
/** UTC clock for this process — models default "new" to stale years without it (see NOTES_FOR_ENG.md). */
function clockPreamble() {
const iso = new Date().toISOString()
const utcDate = iso.slice(0, 10)
const utcYear = Number(iso.slice(0, 4))
return `**Current moment (authoritative for this run):** UTC calendar date **${utcDate}**, calendar year **${utcYear}**. Any \`yearPublished\` filter for "new", "recent", "last year", "just came out", or release timing **must** follow this moment — **never** choose years from training memory alone.`
}
function agentSystemMessage() {
return `${clockPreamble()}\n\n${AGENT_SYSTEM_BODY}`
}
/** Ground the model: BGG uses exact, title-case strings; casual words won’t match `in mechanics`. */
const AGENT_SYSTEM_BODY = `You query a Sanity dataset of board games (_type must be exactly "boardGame", camelCase) imported from BoardGameGeek.
**Query ladder (zero rows on a cooperative ask):** If the user asked for **cooperative** play and the first \`groq_query\` returns **zero** documents: (2) your **next** tool call MUST be another \`groq_query\` with the **same cooperative detection** (Field tips) **and** the same player logic but **no** \`yearPublished\` filter, \`| order(averageRating desc) [0...9]\`. (3) If still zero, drop the player filter but keep cooperative + sort; if still zero, run \`*[_type == "boardGame"] | order(averageRating desc) [0...5]{name, mechanics, categories, ...}\` to prove the dataset is non-empty. **You may not** answer with “there are none”, “it seems”, “issue retrieving”, “couldn’t find”, or “would you like to explore…” until those widening steps have **actually run** when (1) was empty for that cooperative ask. When widening returns games, **list titles** and state what you relaxed.
**Multi-constraint asks (co-op + players + playtime + “new” + several themes):** First \`groq_query\` with **every** constraint you can express with exact BGG strings. On **zero** rows, run **additional** \`groq_query\` calls and relax **one axis at a time** (separate query per relaxation), typical order: drop or widen \`yearPublished\` → widen \`maxPlaytime\` (e.g. try \`maxPlaytime <= 40\` then \`<= 45\` if the user said “under 30 minutes” and nothing matches) → replace **AND** of optional taste tags with **OR** (\`Narrative Choice / Paragraph\` **or** \`City Building\`) while keeping must-haves (often co-op + player count). After a zero hit you need **at least two** widening queries before claiming nothing fits—unless the broadest probe shows **no** \`boardGame\` documents at all.
**No déjà vu:** The dataset may be small. Do **not** answer unrelated questions with the same default \`order(averageRating desc)[0...4]\` slice. Rank and filter for **this** question; prefer games that match **more** of the requested tags over famous titles that ignore half the ask.
**Tool errors:** If \`groq_query\` (or any tool) returns an error, read the message, fix the GROQ, and **retry once** before telling the user data could not be retrieved.
Temporal: the **Current moment** block above is the source of truth for "now". Map "recent" / "new" / "just came out" to \`yearPublished\` using a **range** anchored on that year, not a single stale year. For "last year" in casual speech, people often mean **the last ~12–18 months of releases** — include the **current calendar year** and the previous one (e.g. if the authoritative year is 2026, use \`yearPublished >= 2025\` or \`yearPublished in [2025, 2026]\`), **not** \`yearPublished == 2025\` alone, unless the user explicitly names one past year only. Games "launched" in the current year must not be excluded by a narrow "previous year only" filter. **Do not** use arbitrary old windows (e.g. \`yearPublished in [2022, 2023]\` when the authoritative year is 2024 or later) for "new" — that is incorrect for the user’s intent.
Players: "for N players" or "supports N" means \`minPlayers <= N && maxPlayers >= N\`.
Highly rated: use \`averageRating\` when ordering, but **tie-break toward relevance** to the user’s tags and playtime — not global popularity alone when the user gave specific tastes.
Field tips (arrays of strings — use exact BGG spelling with "in"):
- Worker placement: mechanics contains "Worker Placement"
- Deck building: use "Deck, Bag, and Pool Building" — NOT the casual phrase "Deck Building" alone
- Cooperative: use \`("Co-operative Play" in mechanics || "Cooperative Game" in mechanics || "Cooperative Game" in categories)\` — BGG sometimes lists **Cooperative Game** under mechanics; check **all three**
- Narrative: casual "narrative games" → mechanic **"Narrative Choice / Paragraph"** (exact string); use \`schema_explorer\` if unsure
- City building: category **"City Building"** (exact string)
- Trading / economic: try categories like "Economic", "Negotiation", "Industry / Manufacturing", or search mechanics for "Trading" if present
Prefer schema_explorer or a small exploratory groq_query if unsure of exact tokens.
When you list games (one or many), **always lead with the game’s \`name\`** (bold the title). Never use the designer as the list heading or as a substitute for the title — designers are supporting detail only. Each numbered item must show **Title → then** year, designers, mechanics, categories, players, playtime, then **two or three short sentences** summarizing **only** what appears in those fields (there is **no** long marketing description in the dataset—do not invent story or rules from training data). End each game block with numeric **weight** and **averageRating** when available. Your groq_query projections must include \`name\` whenever you return games.`
let mcpClient
try {
errStep('Connecting to Agent Context MCP…')
mcpClient = await createMCPClient({
transport: {
type: 'http',
url: mcpUrl,
headers: {
Authorization: `Bearer ${readToken}`,
},
},
})
errStep('Loading MCP tools…')
const tools = await mcpClient.tools()
errStep('Calling model (this can take 20–60s)…')
const insightsToken = process.env.SANITY_INSIGHTS_WRITE_TOKEN?.trim()
const insightsProjectId = process.env.SANITY_PROJECT_ID?.trim()
const insightsDataset = process.env.SANITY_DATASET?.trim() || 'production'
let experimental_telemetry
if (insightsToken && insightsProjectId) {
const insightsClient = createClient({
projectId: insightsProjectId,
dataset: insightsDataset,
token: insightsToken,
useCdn: false,
apiVersion: '2026-01-01',
})
experimental_telemetry = {
isEnabled: true,
integrations: [
sanityInsightsIntegration({
client: insightsClient,
agentId: process.env.SANITY_INSIGHTS_AGENT_ID?.trim() || 'bgg-agent-cli',
threadId: randomUUID(),
}),
],
}
errStep('Agent Insights: telemetry on (this run saved to Studio when classification runs)…')
}
const result = await generateText({
model: openai('gpt-4o'),
tools,
stopWhen: stepCountIs(25),
system: agentSystemMessage(),
messages: [{role: 'user', content: question}],
...(experimental_telemetry ? {experimental_telemetry} : {}),
})
const out = textFromResult(result)
if (out) {
if (ansiStderr) {
console.error(chalk.dim('─'.repeat(Math.min(56, (process.stdout.columns || 56) - 1))))
}
console.log(formatAssistantText(out))
if (ansiStderr) {
errDim(`Done · finishReason=${result.finishReason} · steps=${result.steps?.length ?? 0}`)
}
} else {
console.error(
ansiStderr
? chalk.red.bold('✖') +
' ' +
chalk.red(
`Model finished with no assistant text. finishReason=${result.finishReason} steps=${result.steps?.length ?? 0}`,
)
: `Model finished with no assistant text. finishReason=${result.finishReason} steps=${result.steps?.length ?? 0}`,
)
const last = result.steps?.at(-1)
if (last?.toolResults?.length) {
errDim('Last step had tool results but no text — try raising stopWhen or check Agent Context instructions.')
}
process.exitCode = 1
}
} catch (err) {
printDeployHintIfMcpRejected(err)
throw err
} finally {
if (mcpClient) await mcpClient.close().catch(() => {})
}
function printDeployHintIfMcpRejected(err) {
const msg = String(err?.message ?? err)
if (
msg.includes('Only datasets with deployed Studio') ||
msg.includes('-32004')
) {
console.error(
ansiStderr
? chalk.yellow(
'\nAgent Context MCP requires a deployed Studio (v5.1+). Run: npx sanity deploy\n' +
'Local npm run dev is not enough. See README and https://www.sanity.io/docs/ai/agent-context\n',
)
: '\nAgent Context MCP requires a deployed Studio (v5.1+). Run: npx sanity deploy\n' +
'Local npm run dev is not enough. See README and https://www.sanity.io/docs/ai/agent-context\n',
)
}
}The agent script depends on two more packages: the Vercel AI SDK (ai) and an OpenAI provider (@ai-sdk/openai). Install them with npm:
npm install ai @ai-sdk/openai
The Vercel AI SDK is what lets the agent loop — it provides generateText, which drives the back-and-forth between the language model and your MCP tools. Without it, you'd be making raw API calls and manually managing tool call/response cycles yourself.
The OpenAI provider is the adapter that connects generateText to GPT-4o (or whatever model you choose). If you'd rather use Anthropic or another provider, the SDK supports those too, just swap the provider import.
Add three more variables to .env. Create a Sanity API token with Viewer permissions:
SANITY_CONTEXT_MCP_URL=<paste the full URL from the Agent Context document in Studio> SANITY_API_READ_TOKEN=your_viewer_token OPENAI_API_KEY=your_openai_key
This pattern works for your data too
Now you can start building your own agents on top of your content. Here are a few queries that show just how precise this gets:
npm run agent "Find games that combine Worker Placement with Deck Building" npm run agent "What is the BGG complexity weight and average rating of Wingspan?" npm run agent "Which game in the database has the most mechanics listed?"
The agent runs a GROQ expression against real records, reads the result, and gives you an exact answer. It counts array lengths, finds maximums, traverses references, whatever your schema supports.
Swap out board games for a product catalog, a documentation site, a recipe database, or a content library. The architecture is the same. The agents you build for your users get smarter the more you invest in your content, not because a new model dropped, but because your data got better.
What’s happening under the hood
When you ask the agent a question, it reaches the Agent Context MCP server. That server gives the agent three tools to work with: the agent three tools to work with: initial_context (a compressed overview of your schema, field names, and document count), groq_query (full GROQ access to your Content Lake), and schema_explorer (field-level inspection, so the agent constructs accurate queries without guessing at field names).
The agent doesn’t retrieve context and answer from memory. It runs queries, reads the results, and builds its response from live data. The instructions in the Context document guide how it frames and presents those results.
On top of all this, you can wire up any agent, using any AI you want, and have complete control of the experience for you or your end user/customer… all using Typescript.
Your Sanity content becomes structured data the agent can query with the precision of a database.
I am proud to say that after the agent recommended Cozy Stickerville, I actually went and bought it, excited to play it with the fam tonight.

If you are building with Sanity Context or want help figuring out how to use it for your work join our Discord. We will be having some live sessions showing off agents built with Sanity and it’ll be a great place to ask questions.