Your OpenAI agent runs clean in the playground, then ships and starts inventing product SKUs that don't exist. The model isn't broken. It's doing exactly what you asked: filling a gap with the most plausible token. When the tool you handed it returns a vague blob, or no tool fires at all, the model guesses, and a confident guess about your catalog is a hallucination with a straight face.
Most of the fix lives in tool design, which is the half of agent building OpenAI's docs cover lightly. This article spends its first sections there: how function calling actually decides which tool to call, why your JSON Schema descriptions matter more than your system prompt, and how to keep the model from improvising arguments. Then it connects to retrieval. Sanity Context is Sanity's agent-facing product. Its primary surface today is Context MCP, a hosted, read-only MCP endpoint that exposes schema reads, GROQ queries, reference traversal, and optional semantic search across a Sanity dataset. Knowledge Bases is the second surface, for unstructured sources like PDFs and support docs.
The thesis: a well-described tool plus a retrieval source that returns structured, schema-shaped results is what stops the guessing. We'll wire OpenAI's tool calling to a real content source so the model reads instead of inventing.
How OpenAI's function calling actually picks a tool
When you pass a `tools` array to `chat.completions.create` or the Responses API, the model never executes anything. It returns a `tool_calls` block: the name it chose and a JSON string of arguments it generated. Your code runs the tool, appends the result as a `role: "tool"` message, and calls the model again. That round trip is the whole loop. The model's only job at step one is matching the user's intent to one of your tool descriptions and producing arguments that fit the schema.
That means the model decides based on text you wrote, not on what your function does. If two tools have similar descriptions, the model flips a coin. If the description says "get data" it has nothing to discriminate on. Developers spend hours tuning the system prompt when the actual lever is the `description` field of each function and the per-parameter descriptions inside its JSON Schema. OpenAI's models read those at selection time.
The second failure point is argument generation. The model produces the arguments string from scratch, so if your schema allows a free-form `query` string with no constraints, the model will happily pass `"the blue one from last spring"` and your backend has to make sense of it. Tighten the schema and you tighten the model's behavior. Use `enum` for known categories, mark fields `required`, and set `strict: true` so the API guarantees the output conforms to your schema instead of merely trying.
Designing the tool schema so the model stops improvising arguments
Strict function calling is the single biggest reliability upgrade most teams skip. With `strict: true` and `additionalProperties: false`, OpenAI constrains generation to your exact schema, so you never get a stray field or a malformed enum value. The model can still pick the wrong tool, but it can no longer hand you arguments your code wasn't built to parse.
Write descriptions for the model, not for your teammates. "Search published articles by topic and date range. Use when the user asks about content we've shipped, not draft or scheduled posts." tells the model both what the tool does and when not to reach for it. That second clause prevents the most common misfire: the model calling your search tool for a question it should have answered from conversation context.
Keep the argument surface small and typed. A `status` enum of `["published", "draft", "archived"]` is worth more than a paragraph of prompt engineering, because the model literally cannot emit a fourth value under strict mode.
A strict tool definition for the Responses API
Strict mode plus a tight enum stops the model emitting argument values your handler can't parse.
import OpenAI from "openai";
const client = new OpenAI();
const tools = [
{
type: "function" as const,
name: "search_articles",
description:
"Search published articles by topic and date range. Use when the user asks about content we have shipped, not drafts.",
strict: true,
parameters: {
type: "object",
additionalProperties: false,
required: ["topic", "status"],
properties: {
topic: { type: "string", description: "Subject to search for" },
status: {
type: "string",
enum: ["published", "draft", "archived"],
description: "Editorial state to filter by",
},
},
},
},
];
const res = await client.responses.create({
model: "gpt-4.1",
input: "What have we published about retrieval?",
tools,
});Why a clean tool call still returns garbage
You can get the tool selection perfect and the arguments valid, and the agent still hallucinates. That's because the tool's return value is the model's ground truth for the next turn, and a sloppy return value poisons it. If `search_articles` returns a 50KB JSON dump of every field on every match, the model has to pick a needle from a haystack inside its own context window, and it often picks wrong or summarizes inaccurately.
The model treats whatever you put in the `tool` message as fact. Return three matches when the user clearly wanted one, and the model may blend them. Return a record with a null `publishedAt` and no explanation, and the model may invent a date. Return raw database rows with cryptic column names and the model guesses at their meaning. None of this is a model defect; it's a retrieval-shape problem wearing a hallucination costume.
So the real question behind "how do I stop my OpenAI agent from making things up" is usually: what is the tool handing back? The fix has two parts. First, shape the result so it answers the question and nothing more: the fields the user asked about, resolved references instead of opaque IDs, and an explicit signal when there's no match. Second, make the underlying query precise enough that you're returning the right records in the first place. That second part is where most teams are quietly doing keyword `LIKE` matching and wondering why the agent retrieves the wrong document.
The tool result is ground truth to the model
Connecting OpenAI tool calls to Sanity Context over MCP
This is where the tool-design half meets the retrieval half. Instead of writing and maintaining your own `search_articles` handler against a database, you can point the agent at Sanity Context. The fastest way in is Context MCP, a hosted, read-only MCP endpoint that exposes schema reads, GROQ queries, and reference traversal across your Sanity dataset. Because it's MCP, you attach it as a tool source and the agent gets schema-aware tools without you hand-rolling JSON Schema for each one.
OpenAI's Responses API speaks MCP directly. You add the endpoint to the `tools` array as an `mcp` type, and the model can now query your structured content the same way it would call any function, except the tool definitions and the result shapes come from your actual content model. Reference traversal matters here: when an article references an author and a category, the agent can follow those links in one query instead of three round trips, so it never has to guess at an unresolved ID.
The read-only constraint is deliberate and worth stating to your team. Context MCP reads; it does not write. An agent can retrieve, filter, and traverse, but it cannot mutate your dataset through this endpoint. Writes go through Agent Actions, a separate path. For a customer-facing or internal-knowledge agent, read-only is exactly the boundary you want.
Attaching Context MCP as a tool source on the Responses API
The model gets schema-aware, read-only tools from your Sanity dataset without you defining each function by hand.
import OpenAI from "openai";
const client = new OpenAI();
const res = await client.responses.create({
model: "gpt-4.1",
input: "What have we published about retrieval, and who wrote it?",
tools: [
{
type: "mcp",
server_label: "sanity_context",
server_url: "https://mcp.sanity.io/<project>/<dataset>",
require_approval: "never",
},
],
});
console.log(res.output_text);When structured retrieval beats vector search (and when it doesn't)
There's a reflex to answer every retrieval problem with embeddings: chunk the content, store vectors, do similarity search. For a lot of agent failures that's the wrong first move. When a user asks "what did we publish about retrieval last quarter," the constraint isn't semantic, it's structural: a date range, a publication status, maybe an author. Pure vector search can't honor those predicates reliably; it ranks by meaning and happily returns a draft from two years ago because the text is similar.
This matches what Sanity sees in production: the heavy majority of agent calls are structured GROQ queries and schema lookups, not semantic search. Embeddings are opt-in and off by default, and most projects running on Context MCP never turn them on, because the questions agents ask about structured content are answerable with predicates and joins. Vector search is one ingredient, not the meal.
The discipline that does work is hybrid, but hybrid means structural predicates plus keyword matching plus optional semantic scoring, layered inside one query. In GROQ you filter on the structured fields first, then rank within those results. You only reach for semantic similarity when the agent's failures genuinely call for it, for example fuzzy topical questions over a large article corpus where keyword match misses synonyms. For unstructured sources like PDFs, websites, or support transcripts, that's the job of Knowledge Bases, the second Sanity Context surface, which turns messy documents into ordered, retrievable content. Structured content stays in GROQ; messy content goes through Knowledge Bases.
Embeddings are off by default for a reason
A hybrid GROQ query when the agent does need semantic ranking
When you do hit the case where structure alone isn't enough, say a support agent searching a knowledge corpus where users phrase the same problem ten different ways, the move is to combine both signals in a single query rather than bolting a separate vector store onto your stack. GROQ's `score()` pipeline lets you filter structurally first, then rank the survivors by a blend of keyword match and semantic similarity.
The key is order of operations. Your structural predicates live in the `*[ ... ]` filter, so you've already discarded the wrong publication states and out-of-range dates before any scoring happens. Then `score()` combines `boost()` on a `text::query()` keyword match with `text::semanticSimilarity()` on the query text, and `order(_score desc)` ranks the result. The model receives a small, ranked, already-filtered set instead of a haystack, which is exactly the return shape that stops it from blending or inventing.
Notice what's not in the snippet: there's no separate embedding column you manage, no sync job keeping a vector DB in step with your CMS, and no second source of truth. The keyword and semantic signals operate over the same content the structural filter just narrowed. That's the practical payoff of doing retrieval where your structured content already lives, rather than reassembling it from a vector DB plus a JSON blob plus a custom sync.
Hybrid retrieval in a single GROQ query
Structural predicates filter first, then score() blends keyword boost and semantic similarity over the same content.
*[_type == "article" && status == "published"]
| score(
boost(title match text::query($queryText), 3),
text::semanticSimilarity($queryText)
)
| order(_score desc)[0...5]{
title,
"author": author->name,
publishedAt
}Where this leaves the build
Step back and the two halves snap together. The tool-design half is OpenAI's job: strict function definitions, descriptions written for the model's selection step, tight enums and required fields so arguments are valid by construction, and result shapes that answer the question without dumping noise. Get that right and you've removed most of the surface area where agents improvise.
The retrieval half is about what those tools hand back. A tool is only as good as its return value, and the return value is only as good as the query behind it. Pointing OpenAI's tool calls at Sanity Context over Context MCP gives you schema-aware, read-only tools whose results are shaped by your actual content model, with reference traversal so the agent never juggles unresolved IDs, and a hybrid query path for the minority of cases that genuinely need semantic ranking.
This is the role Sanity plays in an AI stack. Sanity is the Content Operating System for the AI era, the intelligent backend for companies building AI content operations at scale, where the same governed, versioned, human-edited content that powers your site also feeds your agents. The agent reads from one source of truth instead of a copy that drifted. Your editors keep using the Studio, your agent queries the result, and the thing the model treats as ground truth is actually true. That's the whole point of doing the tool-design half well: it only pays off when the tool returns something worth trusting.