Frameworks & Hosting

Deploy Cloudflare Workers-hosted AI agents on Sanity Context

Cloudflare Workers

Deploy AI agents to the edge on V8 isolates with sub-millisecond cold starts, no Node runtime, and a global footprint that runs your code close to every user.

Visit Cloudflare Workers

Your agent runs fine on your laptop. Then you deploy it to a Cloudflare Worker and the first request throws `Dynamic require of "fs" is not supported`, or your retrieval step times out because you bundled a Node SDK that assumes a filesystem and a 512MB heap. Workers run on V8 isolates, not Node. No `fs`, no `net` sockets the way you expect, a CPU budget measured in milliseconds, and a bundle that has to stay small. Half the agent libraries on npm were never built for that.

This is where Sanity Context fits. 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. A second surface, Knowledge Bases, turns unstructured sources like PDFs and support docs into ordered, retrievable documents. Because retrieval lives behind an HTTP endpoint, the heavy work happens off the isolate, and your Worker stays small and fast.

This article is about what is actually fast on Workers: where the CPU budget goes, how to keep a cold start cold-fast, why you should never bundle a heavy content SDK into an edge function, and how to wire structured content retrieval through an HTTP GROQ call or the Context MCP endpoint so your edge-rendered agent gets grounded content without blowing its time budget.

What actually counts against your Worker's budget

A Cloudflare Worker is not a container. It is a V8 isolate, the same primitive that runs a browser tab, spun up per request and billed against CPU time, not wall-clock time. That distinction is the whole game. Awaiting a `fetch` to an external API costs you almost nothing on the CPU meter, because the isolate is parked while the network does its work. Parsing a 2MB JSON blob, running a regex over a long string, or instantiating a fat SDK on every request costs you real CPU and pushes you toward the limit.

The free tier gives you 10ms of CPU per request. Paid Workers default to 30 seconds of wall-clock but still want you under a tight CPU budget. The error you hit when you blow it is `Error: Worker exceeded CPU time limit`, and it shows up under load, not in testing, which is the worst time to find it.

For an agent, this reframes the design. The LLM call is a `fetch`, so it is cheap on CPU. The retrieval call is a `fetch`, also cheap. What is expensive is anything you do in-process between those awaits: deserializing large payloads, embedding text locally, running a vector similarity loop, or pulling in a library that does heavy work at import time. The fast architecture keeps the isolate thin and pushes computation to services on the other side of a `fetch`. Your Worker becomes an orchestrator that mostly waits.

â„šī¸

CPU time, not wall-clock time

Workers bill CPU time. An `await fetch()` that takes 800ms over the network barely registers against your CPU budget, because the isolate is suspended while waiting. The thing that kills you is synchronous work: JSON.parse on a large response, a local embedding model, or an SDK that does setup on import. Move computation behind a fetch and your Worker stays cheap.

Why bundling a heavy content SDK into the edge is the wrong move

The instinct when you need content in an agent is to `npm install` the SDK and import it. On Workers, that instinct backfires twice. First, many SDKs reach for Node built-ins. You ship the Worker, it builds clean, and at runtime you get `Dynamic require of "crypto" is not supported` or `No such module "node:stream"`. The `nodejs_compat` flag in `wrangler.toml` papers over some of this, but it inflates your bundle and your cold start.

Second, even a Workers-compatible SDK adds weight to a bundle that has a hard size ceiling and a startup cost on every cold isolate. The Worker that imports three SDKs is slower to spin up than the one that makes three `fetch` calls to plain HTTP endpoints.

The pattern that holds up is to talk to content over HTTP directly. Sanity's GROQ query API is a single HTTPS GET. No SDK required, no Node built-ins, nothing to bundle. You construct a URL, `fetch` it, and parse the result. This is the same call the official `next-sanity` client makes under the hood, but stripped to what a Worker actually needs. Keep the heavyweight client for your Node build steps and use the raw HTTP API at the edge.

Query Sanity content from a Worker over plain HTTP

A read token in a Worker secret, one fetch, one parse. Nothing that touches the Node runtime.

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const projectId = env.SANITY_PROJECT_ID;
    const dataset = env.SANITY_DATASET;
    const apiVersion = '2024-03-01';

    // GROQ query, URL-encoded. No SDK, no Node built-ins.
    const query = `*[_type == "article" && defined(slug.current)]
      | order(_updatedAt desc)[0...5]{ title, "slug": slug.current, summary }`;

    const url =
      `https://${projectId}.api.sanity.io/v${apiVersion}/data/query/${dataset}` +
      `?query=${encodeURIComponent(query)}`;

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${env.SANITY_READ_TOKEN}` },
    });

    const { result } = await res.json();
    return Response.json({ articles: result });
  },
};

Cold starts, isolates, and keeping the agent loop thin

Workers have famously fast cold starts, often under 5ms, because spinning up a V8 isolate is cheap compared to booting a container. You lose that advantage the moment your top-level module does real work. Code that runs at import time runs on every cold isolate. A library that compiles a schema, builds a 50KB lookup table, or initializes a client with retries on import will turn a 5ms cold start into something you can feel.

The fix is structural: keep the top level of your Worker free of side effects, and defer all setup into the request handler where it runs lazily and can be cached across requests on a warm isolate. Construct clients inside `fetch`, not at module scope, unless the client is genuinely stateless and cheap to build.

For an agent, the loop itself should be a sequence of awaits with no heavy in-process steps between them: read the user input, `fetch` retrieval, `fetch` the LLM, return. If you find yourself running an embedding model or a similarity search in the Worker, that is the signal you have put computation in the wrong place. Move it behind an HTTP call. The retrieval service does the vector math; your Worker just asks for the answer and waits. This is also why a hosted retrieval endpoint matters more on Workers than it does in a Node deployment: there is no warm process to amortize heavy setup over.

âš ī¸

Top-level imports run on every cold isolate

Anything at module scope, including the side effects of an `import`, executes when a fresh isolate boots. A content SDK that builds an internal cache or compiles a schema on import quietly taxes every cold start. Profile your bundle's import cost, not just its size, and push setup into the request handler.

Structured retrieval over HTTP: the GROQ path

Most agent retrieval failures on the edge are not model problems, they are retrieval problems, and most retrieval is structured, not semantic. When a user asks your support agent "what changed in the v3 release for enterprise plans," the query has structure: a version, a plan tier, a document type. Pure vector similarity over a pile of embeddings will happily return a v2 doc about the wrong tier because the prose is close. The structural predicates are what make the answer correct.

This is the strength of routing structured content through Sanity Context's GROQ retrieval. A GROQ query expresses those predicates directly inside the filter: document type, reference to a plan, a publication state, a date range. The agent gets exactly the documents that match, in the shape the schema defines, over a single HTTPS call that costs your Worker almost no CPU. Reference traversal happens server-side, so following an article to its author to that author's other posts is one query, not three round trips from the isolate.

In production, this is the heavy majority of calls: GROQ queries and schema lookups with a compressed initial context behind them. Semantic search is a small slice, opt-in, and off by default. Building your edge agent around structured retrieval first is not a compromise, it is the path that matches what actually ships. You reach for embeddings only when the structured query genuinely can not express the user's intent.

A typed GROQ retrieval tool the agent calls per turn

The plan tier and topic are real filters the model can't fake. The agent gets correct documents, not just similar ones.

async function retrieveContext(
  env: Env,
  args: { topic: string; planTier: string },
): Promise<unknown[]> {
  // Structural predicates live inside the *[ ... ] filter.
  const query = `*[
    _type == "releaseNote" &&
    planTier == $planTier &&
    references(*[_type == "product" && slug.current == $topic]._id)
  ] | order(publishedAt desc)[0...8]{
    title, version, body, publishedAt
  }`;

  const params = encodeURIComponent(JSON.stringify(args));
  const url =
    `https://${env.SANITY_PROJECT_ID}.api.sanity.io/v2024-03-01/data/query/` +
    `${env.SANITY_DATASET}?query=${encodeURIComponent(query)}` +
    `&$planTier="${args.planTier}"&$topic="${args.topic}"`;

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${env.SANITY_READ_TOKEN}` },
  });
  const { result } = await res.json();
  return result;
}

When you do need semantic search: hybrid in one query

Sometimes the structural query is not enough. A user asks something fuzzy, phrased nothing like your content, and no clean predicate captures it. This is where semantic similarity earns its place, but the mistake is treating it as a separate system: embed the query, hit a vector DB, get back IDs, then go fetch those documents from your content source and stitch the results together. On a Worker that is two services, two round trips, and reconciliation logic burning your CPU budget.

Sanity Context collapses that into one GROQ query. Semantic similarity is a scoring function inside the same query that already carries your structural filters. You keep the predicates that guarantee correctness, type, plan, publication state, and add a semantic score on top, then order by the combined score. One HTTPS call, one parsed response, the documents themselves rather than bare IDs to chase. The structural filter runs first and narrows the candidate set, so the semantic ranking operates on documents that are already valid.

Remember the discipline: this is opt-in, and most projects shipping on Context MCP never enable embeddings. Hybrid means structured predicates plus keyword matching plus optional semantic scoring, not embeddings on everything. Reach for it when your structured retrieval demonstrably returns the wrong document for a class of queries, and not before. For genuinely unstructured corpora, PDFs, scraped sites, support exports, point Knowledge Bases at them instead of trying to force them through a schema.

Hybrid retrieval: structural filter plus semantic score in one GROQ query

*[_type == "article" && planTier == $planTier]
| score(
    boost(title match text::query($queryText), 3),
    text::semanticSimilarity($queryText)
  )
| order(_score desc)[0...8]{
  _score, title, body, planTier
}

Wiring it through the Context MCP endpoint

Writing typed GROQ tools by hand gives you full control, and for a tight agent that is often what you want. But if your agent loop already speaks the Model Context Protocol, there is a faster way in. Context MCP is a hosted, read-only MCP endpoint, so any MCP-capable agent attaches it as a server and gets schema-aware tools out of the box: schema reads, GROQ queries, reference traversal, and optional semantic search, without you authoring each tool wrapper.

On Workers this is a clean fit because MCP is just HTTP. The endpoint is hosted by Sanity, so none of the retrieval computation runs on your isolate. Your Worker holds the connection, forwards the agent's tool calls, and relays results. The read-only constraint is worth stating plainly: an agent reaching Sanity through MCP can query and traverse content, but it can not write. Mutations go through Agent Actions, a separate path, which is exactly the boundary you want when an autonomous loop is touching production content from the edge.

This is the broader case for Sanity as the intelligent backend for companies building AI content operations at scale. The same content that powers your website is what the agent retrieves, governed in Sanity Studio, versioned through Content Releases, and editable by humans before it ever reaches a model. Your Cloudflare Worker stays a thin, fast orchestrator at the edge, and the structured content, the schema, and the governance live where they belong. The agent gets correct, current, reviewable content over an HTTP call that barely touches its CPU budget.

✨

Read-only at the edge is a feature

Context MCP is read-only by design. An edge agent can query, traverse references, and search your content, but it can not mutate it. Writes go through Agent Actions, a deliberate, separate path. For an autonomous loop running on a public Worker, that boundary keeps a hallucinated tool call from ever corrupting production content.