Platform & Implementation8 min readยท

Integrating Function Calling With a Content API: Giving Agents Tools, Not Documents

You gave your agent a search endpoint, it returned a wall of prose about three products, and the model re-narrated it into a fourth product that does not exist.

You gave your agent a search endpoint, it returned a wall of prose about three products, and the model re-narrated it into a fourth product that does not exist. That is the failure mode most teams hit first: they expose documents to the model and call it a tool. A document is something to read. A tool is a function the model can call, with a defined authority and a defined shape of return data, and the difference decides whether your agent can actually do anything in your systems.

This article reframes function calling against a content API as a design problem with three axes: which functions you expose, with what authority, returning what shape of data. Get those right and the model books the flight, cancels the subscription, or returns three product objects it can pass straight through. Get them wrong and you have a chatbot that hallucinates against your own catalog. We will walk the three tool categories every production agent needs, why the return shape matters more than the prompt, how auth forwarding makes the agent act as the user, and where Sanity Context fits.

What are the three categories of tools every production agent needs?

A production agent needs read tools, write tools, and composite tools, and the distinction is about authority and blast radius, not about phrasing. A tool is a function the model can call. The architectural question is never "what can the model say" but "which functions do you expose, with what authority, returning what shape of data."

Read tools query content, fetch user state, and look up product info. The auth boundary is usually the user's session token, so the agent reads as the user and sees only what that user is allowed to see. Write tools mutate state: move a seat on a flight, cancel a subscription, open a support ticket. The auth boundary is almost always the user's token too, because the agent acts as the user, and an action taken under a service account is an action nobody can trace back to a person. Composite tools wrap a workflow, mapping one tool call to three API calls in your backend. They are useful precisely when you do not want the model orchestrating multi-step work itself, because every step the model orchestrates is a step where it can pick the wrong order or drop an argument.

The temptation is to expose everything and let the model figure it out. Resist it. A read tool that can also write is a read tool that can delete a user's account when the model misreads intent. Draw the categories explicitly, and the auth boundary and the failure surface become something you can reason about before you ship, not something you discover in an incident review. Sanity Context leans on this same split: Context MCP is a hosted read-only endpoint, deliberately, so the read path cannot mutate content even when an agent asks it to.

Illustration for Integrating Function Calling With a Content API: Giving Agents Tools, Not Documents
Illustration for Integrating Function Calling With a Content API: Giving Agents Tools, Not Documents

Why should a content API tool return structured data, not prose?

A tool should return schema-shaped data, not a stream of text, because a tool that returns prose forces the model to paraphrase, and paraphrasing is where facts go to die. This is the most consequential and most ignored decision in the whole tool layer. If your agent is supposed to return three products, the tool should return three product objects, not a paragraph describing them.

Sanity watched agents get built against Context MCP, and the pattern was stark: the ones that worked returned schema-shaped responses the model could pass straight through, and the ones that struggled got a wall of text back and re-narrated it, badly. A price of $149 in a JSON field stays $149. The same price embedded in a sentence the model has to summarize can come back as "around $150" or, worse, silently attached to the wrong item. Structured returns also let the client render natively. The Vercel AI SDK's generative UI primitives let a tool return a React component directly, and the emerging Model Context Protocol UI spec extends the same idea so tool outputs can carry structured hints like a date picker, a comparison table, or a confirmation dialog. Those UI paths are still maturing, so treat them as a direction rather than a finished dependency.

The deeper point is that the return shape is where a content API earns its keep. Because content in the Content Lake is already modeled as structured documents queried with GROQ, a tool can select exactly the fields the model needs and hand back objects rather than paragraphs. You are not un-flattening prose back into structure; the structure was never lost.

How many tools should an agent have, and why do descriptions matter?

An agent should have roughly ten focused tools rather than fifty overlapping ones, because tool descriptions are part of the prompt and every description you add competes for the model's attention on every single turn. Ten focused tools beat fifty overlapping ones because the model can hold the whole menu in context and route to the right one.

Here is the mechanism people miss. In most tool-calling harnesses, including the Vercel AI SDK, Anthropic's tool use, and OpenAI's function calling, the tool schemas and descriptions ride along with every request so the model knows what is available. They are not free. They consume context tokens on turn one and turn fifty alike. Push past roughly forty tools and the model gets confused, picks the wrong one, and calls it with the wrong arguments. The symptom looks like a reasoning failure; the cause is a menu that is too long to read.

There is also a security dimension. Tool descriptions are trusted text. If you install a Model Context Protocol server, its tool descriptions land in your prompt every turn, and a sloppy or malicious one can prompt-inject your agent before the user has typed a word. So the discipline is twofold: keep the tool set small and legible for routing accuracy, and treat every third-party tool description as untrusted input to your own prompt. A content API that exposes a handful of well-named, schema-aware tools, rather than a sprawling surface of near-duplicate endpoints, is doing the model a favor it will repay in fewer wrong calls.

How does auth forwarding make the agent act as the user, not the system?

Auth forwarding means the agent's reach is the user's reach, achieved by carrying the user's session token from the web app, through the agent runtime and the tool layer, to the backend API, so every call runs under the user's permissions rather than the agent's. This is the single most underbuilt thing in production agents, and it is the difference between a demo and something you can put in front of regulated customers.

Concretely, the session token never leaves the user's identity context. The API call to the booking service is made under the user's permissions, not a shared service account. That buys you three things at once. Personalized retrieval, because the agent sees only the content and records that user is entitled to. Personalized action, because a write happens as that user and lands in the right place. Traceable audit, because every action ties back to a real identity. A side benefit is that the agent inherits your existing security model with no new policy engine to build: the same row-level permissions, the same rate limits, and the same regulatory boundaries you already enforce apply automatically.

In Sanity's tool code the pattern is explicit. A findOrderTool takes the userToken and issues sanityClient.withConfig({ token: userToken }).fetch(ORDER_QUERY, { orderId }), so the query for an order runs as the user and returns only orders that user owns. The model is yours too; Context MCP does not care which LLM you point at it. The token, the permissions, and the audit trail belong to your systems, and the agent simply borrows the user's identity for the length of a call.

How should tools handle errors so the agent can recover?

Tools should return structured, typed errors rather than throwing bare exceptions, because a structured error does two jobs at once: it gives your tracing a typed event and it gives the model something to reason about and recover from. As the Sanity team puts it, the model handles errors when you write the errors for it.

Walk the two paths. A bare exception bubbles up as an unhandled error. The run dies, the user sees a spinner that never resolves, and your traces show a stack trace with no semantics. Now return { error: 'Session expired. Please log in again.' } instead. On the tracing side that becomes a typed event you can attach to an OpenTelemetry span, with attributes, exceptions, and status codes you can alert on. On the model side it becomes a signal the agent can act on: it can ask the user to re-authenticate rather than inventing an order that does not exist. In the findOrderTool example, a 401 from the fetch is caught and mapped to exactly that structured message rather than allowed to crash the loop.

This is where function calling against a content API stops being plumbing and becomes reliability engineering. Every error you leave unstructured is a place the model will improvise, and improvisation against missing data is precisely how hallucination gets into production. Enumerate the failure modes, session expired, not found, forbidden, rate limited, and return each as a small object with a code and a human-readable message. You are not just handling errors; you are writing the recovery instructions the model reads on the fly.

How does a content API do hybrid retrieval as a tool, not a document dump?

A retrieval tool over a content API should hand the model a ranked set of structured candidates, and in Sanity that happens natively inside a single GROQ query that blends keyword and semantic ranking rather than shipping documents to an external index. Retrieval is a tool like any other: it takes arguments, it runs under the user's token, and it returns objects, not a paragraph.

The shape of the query matters. Consider a request like "trail runners under $150." The predicates do the filtering that has to hold exactly, category, price, stock location, and the score pipeline does the ranking. In GROQ that is *[ _type == "product" && category == $category && price < $maxPrice ] | score( boost([title] match text::query($queryText), 2), text::semanticSimilarity($queryText) ) | order(_score desc) [0...10]. A BM25 keyword match on the title, weighted 2x with boost(), rides alongside text::semanticSimilarity() across the document, blended by score(). One important constraint: text::semanticSimilarity() is only valid as an argument to score(). Semantic search ranks, it does not filter, so you narrow the candidate set with a filter first, then rank what is left.

The operational payoff is freshness. Vector DB and glue stacks can do hybrid retrieval too, but they require you to build and maintain a separate re-embedding pipeline, and they store vectors, not the governed content your write tools act on. When retrieval is wired into the content backend, dataset embeddings stay tied to the content and updates propagate within minutes, so the freshness problem stops being something you maintain. In production, structured GROQ queries and schema lookups dominate these calls; semantic search is a small slice, and embeddings are opt-in, off by default.

Where does Sanity Context fit in an agent tool architecture?

Sanity Context is the intelligent backend for companies building AI content operations at scale, and in an agent tool architecture it is the layer your read, write, and retrieval tools call so that agents get structured, governed access to content instead of a pile of documents to re-narrate. It is not only an MCP; the mental model to keep is that Sanity Context has an MCP, a knowledge base, and an ingest path.

Context MCP is one surface: a hosted read-only endpoint any agent loop can connect to, serving GROQ mode for querying a dataset at request time and Knowledge Base mode for serving an index built ahead of time. GROQ mode has a prerequisite worth naming, a deployed schema for that project and dataset via sanity schema deploy on Studio v5.1.0 or later, because schema is what lets a tool return exactly the fields the model needs. On the write side, Agent Actions give you schema-aware APIs for content workflows like generate, transform, and translate, which map cleanly to the write and composite tool categories. And because agent instructions live in the Studio, editors can stage agent behavior through Content Releases the same way they stage the website.

This is where the Content Operating System framing earns its place. A legacy CMS stops at publishing and bolts AI on afterward; Sanity Context is built for agents to operate content end to end, from a governed read through a schema-aware write, under the user's own permissions, with errors and return shapes designed for a model to consume. That is the difference between giving an agent documents and giving it tools.

Function calling against a content API: native tools vs assembled stacks

FeatureSanityPinecone / pgvectorContentfulLangChain.js (self-built)
Hybrid retrievalNative: text::semanticSimilarity() inside score() blended with a boosted match() in one GROQ query, filter first then rank.Supported, but you assemble keyword and vector scoring yourself and blend results in application code outside the store.No native hybrid retrieval; delivery API plus an external search service you wire up and keep in sync.Available via retriever chains you compose by hand, blending and re-ranking logic is bespoke code you own and test.
Index freshnessDataset embeddings are tied to content, so updates propagate within minutes; no separate re-embedding pipeline to run.Requires a separate re-embedding and upsert pipeline you build and monitor to keep vectors matching source content.External index must be re-synced on publish through webhooks and glue code you maintain.Freshness is your job: you write the change detection, re-embed, and re-index steps as custom jobs.
Structured tool returnsGROQ selects exactly the fields a tool needs and returns schema-shaped objects the model passes straight through.Returns vectors and metadata; joining back to full governed content objects is left to your application.Returns content entries via API, but schema is coupled to a fixed storage and UI, less shaping control per tool.You define the output shape entirely, which is flexible but means every tool schema is hand-written and maintained.
Auth forwardingForward the user's token: sanityClient.withConfig({ token }) runs each query under the user's row-level permissions.Namespaces and metadata filters exist, but per-user identity and row-level auth are enforced in your own layer.Roles exist in the platform, but forwarding a user identity through the agent to per-record reads is DIY.Entirely bespoke: you thread the session token through the runtime and tool layer as custom code.
MCP endpoint for agentsHosted read-only Context MCP with GROQ mode and Knowledge Base mode; connect any agent loop or LLM you choose.No native MCP surface shaped to content tools; you expose your own endpoints around the vector store.No native MCP surface for agent tools; App Framework plus custom services fill the gap.You can build MCP servers, but the server, its tools, and their descriptions are all yours to write and secure.
Governance of agent behaviorAgent instructions live in the Studio and stage through Content Releases the same way editors stage the website.Vector store has no editorial governance layer; instructions and behavior live in your application code.Editorial workflows exist for content, but not a staging path for agent instructions and tool behavior.No governance layer behind it; tool schemas and prompts are config in code with whatever review you add.