Frameworks & Hosting

Deploy Bun-hosted AI agents on Sanity Context

Bun

A fast all-in-one JavaScript runtime, bundler, and package manager with a native test runner and TypeScript support, built for low-latency server apps.

Visit Bun

You ship an AI app on Bun because the cold starts are tiny and `bun install` finishes before Node has finished resolving its lockfile. Then your agent goes to production and the runtime stops being the bottleneck. The agent answers confidently about a product variant that was discontinued last quarter, or it cites an article that exists in a draft nobody published. The model is fine. The retrieval is wrong, and no amount of Bun's speed fixes a fast call to the wrong data.

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 (PDFs, websites, support exports) into well-ordered documents your agent can read. Neither replaces your Bun server or your agent loop. They sit underneath, feeding the model governed content instead of a stale JSON blob.

This article is about building Bun-native AI apps: the starter shape that actually works, the runtime gotchas that bite in production (fetch quirks, the test runner, native modules), and where Sanity Context fits when your retrieval, not your runtime, is the thing failing.

The Bun-native AI app starter shape

Start with the smallest thing that runs. Bun ships an HTTP server, a fetch implementation, a test runner, and TypeScript support with no build step, so a working AI endpoint is a single file. You do not need Express, you do not need ts-node, and you do not need a separate tsconfig dance to import a `.ts` file.

The shape that holds up: one `Bun.serve()` entry point, a route that takes a user message, a call out to your LLM provider over `fetch`, and a JSON response. Keep the agent logic in its own module so the server file stays a thin transport. Bun's `Bun.serve` returns immediately and handles concurrency for you, so the only thing you own is the request handler.

The trap people hit first is reaching for Node-isms that Bun supports but does not need. You can `import express from 'express'` and it will work, but you have just added a dependency that duplicates what `Bun.serve` already does, and you have given up the typed `Request`/`Response` web-standard API that the rest of your AI stack (the Vercel AI SDK, the OpenAI SDK, MCP clients) already speaks. Stay on web standards and your code stays portable to Cloudflare Workers and Deno later. Read environment config with `Bun.env` or plain `process.env`, both work, and load secrets from a `.env` file that Bun reads automatically with no `dotenv` import.

Minimal Bun AI server

One file, no Express, no build step. Bun reads .env automatically.

import { handleAgentTurn } from "./agent";

const server = Bun.serve({
  port: 3000,
  async fetch(req) {
    const url = new URL(req.url);
    if (url.pathname === "/chat" && req.method === "POST") {
      const { message } = await req.json();
      const reply = await handleAgentTurn(message);
      return Response.json({ reply });
    }
    return new Response("Not found", { status: 404 });
  },
});

console.log(`Listening on http://localhost:${server.port}`);

The bits that bite: fetch, native modules, and the test runner

Three things catch people once the app leaves localhost.

First, streaming. Bun's `fetch` is web-standard, but when you stream LLM tokens back to a client you return a `ReadableStream` from your handler, and you have to actually return it, not `await` it. A common bug is calling `response.body.getReader()` on the upstream provider response and forgetting to pipe it through, so the client hangs until the whole completion finishes. Pass the stream straight into `new Response(stream)` and set `Content-Type: text/event-stream` yourself; Bun will not guess it.

Second, native modules. Some AI tooling pulls in packages with native bindings (a tokenizer, a local embedding model, an `sqlite` driver that is not Bun's built-in `bun:sqlite`). Bun's Node compatibility is good but not total, and a package that calls a node-gyp build can fail with `dlopen` errors at runtime rather than install time. Prefer Bun's built-ins (`bun:sqlite` over `better-sqlite3`) and check that anything with a postinstall step actually loads before you wire it into a request path.

Third, the test runner. `bun test` is fast and Jest-compatible enough that most suites run unchanged, but `bun:test` is its own module. Import `expect`, `test`, and `mock` from `bun:test`, not from `@jest/globals`. Mocking a network call for an agent test means `mock.module("./llm", () => ({ complete: mock(() => "stubbed") }))`, and that mock has to be registered before the module under test is imported, which trips people coming from Jest's hoisting behavior.

âš ī¸

dlopen failures hide until runtime

A native dependency that builds fine with `bun install` can still throw `dlopen` errors the first time a request touches it. Load every native-binding package once at boot (require it in your entry file) so a broken binding fails the deploy, not the first user.

Why a fast runtime does not fix a wrong answer

Once the server is solid, the failures move up the stack. Your Bun app responds in single-digit milliseconds and still tells a customer the wrong return policy. That is not a runtime problem and it is not usually a model problem either. It is a retrieval problem.

The naive fix is to dump everything the agent might need into the system prompt: a JSON export of your product catalog, the current FAQ, some brand guidelines. This works in a demo and rots in production. The export goes stale the moment someone edits a product in your CMS. It blows the context window as the catalog grows. And it gives the agent no way to ask a precise question like "the published articles by this author from the last 30 days" because that structure is gone the instant you flatten it to a blob.

The real fix is to let the agent query live, structured content at request time with the predicates that matter (publication state, date range, reference, variant) intact. In a Bun handler that means making a network call during the turn to a content source that understands those predicates. You already have `fetch`; the question is what is on the other end. Treating the data source as a queryable system instead of a frozen export is the shift that separates a demo agent from one you can put in front of users.

â„šī¸

Most retrieval is structured, not semantic

Across production agents on Sanity Context, the heavy majority of calls are structured GROQ queries and schema lookups. Semantic search is a small slice and embeddings are off by default. Reach for vector search when structured filters genuinely cannot express the query, not as the first move.

Wiring Sanity Context into a Bun agent over MCP

The fastest way to give a Bun agent governed content is the Context MCP endpoint. Context MCP is a hosted, read-only MCP server that exposes schema reads, GROQ queries, and reference traversal across your dataset. Your agent loop attaches it as an MCP server and gets schema-aware tools without you hand-writing a single query.

If you are using the Vercel AI SDK or any MCP-capable client, you point it at the endpoint and the tools show up in the model's tool list. The read-only constraint is deliberate and worth saying out loud: an agent connected over Context MCP can read, query, and traverse your content, but it cannot write. Writes go through Agent Actions, a separate path, so a prompt-injected instruction to "update the price to zero" has nothing to act on. For a production agent exposed to user input, that boundary is doing real work.

Bun runs the MCP client the same way Node would; the SDK is plain TypeScript over `fetch` and streams, both of which Bun implements natively. You do not need a Bun-specific MCP package. The integration is just an HTTP connection to the hosted endpoint plus your existing model call.

Attach Context MCP to an agent in a Bun handler

The Vercel AI SDK MCP client runs unmodified on Bun; tools come from the read-only endpoint.

import { experimental_createMCPClient } from "ai";
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";

const mcp = await experimental_createMCPClient({
  transport: {
    type: "sse",
    url: "https://<projectId>.api.sanity.io/mcp",
  },
});

export async function handleAgentTurn(message: string) {
  const tools = await mcp.tools();
  const { text } = await generateText({
    model: openai("gpt-4o"),
    tools, // schema reads, GROQ queries, reference traversal
    prompt: message,
  });
  return text;
}

Going lower: a typed GROQ tool for full query control

MCP is the default and it covers most cases. When you want exact control over what the agent can query (a narrow set of typed queries instead of the full schema surface), write a thin `tool()` wrapper around a GROQ query and run it with the Sanity client. This is the second integration path, and it is the one to reach for when you want to constrain the agent to a handful of well-tested queries rather than expose the whole dataset.

The payoff of GROQ here is that structural predicates and ranking live in one query. You filter on publication state and date and reference inside the `*[ ... ]` predicate, and if a query genuinely needs semantic ranking you add it with `score()` and `text::semanticSimilarity()` over the candidate set, then `order(_score desc)`. The structural filter narrows to the right documents first; semantic similarity ranks within them. That ordering is the whole point. Pure vector search over your entire corpus returns the embedding-nearest document, which is often the wrong one because the user's query had a structural component (a date, an author, a status) that embeddings cannot resolve.

The Sanity client is `fetch`-based, so it runs on Bun with no adapter. For unstructured sources (PDFs, scraped help-center pages, support ticket exports) the right surface is not GROQ at all but Knowledge Bases, which turns those messy inputs into ordered documents the agent can retrieve against. Route structured content through GROQ and unstructured content through Knowledge Bases; do not force a PDF into a schema or a product catalog into a vector store.

Hybrid retrieval in one GROQ query, called from Bun

*[_type == "article"
  && status == "published"
  && publishedAt > now() - 60*60*24*30
]
| score(
    boost(title match text::query($queryText), 2),
    text::semanticSimilarity($queryText)
  )
| order(_score desc)
[0...5]{ title, slug, excerpt, _score }

Editorial state belongs in Sanity, chat state does not

One more boundary worth drawing, because Bun apps tend to blur it. Not all of your agent's state belongs in the same place.

Ephemeral per-user chat history, the running conversation, rate-limit counters, session tokens, none of that belongs in your content system. That is Redis or Upstash territory, and Bun talks to either over their standard clients with no friction. Keep it there. It is high-churn, disposable, and nobody needs to review it before it goes live.

The other kind of state is editorial: the agent's system instructions, the approved answers to common questions, brand voice rules, the knowledge base the agent retrieves from. That state should be versioned, edited by humans, reviewed, and previewable before it reaches users. This is where Sanity fits as the Content Operating System for the AI era, the intelligent backend for teams building AI content operations at scale. The Content Releases primitive lets an editor stage a change to the agent's approved responses, preview how the agent behaves with the new content, and publish atomically, without a redeploy of your Bun app and without an engineer editing a prompt string in source control. Your runtime stays fast and stateless; the content that shapes the agent's answers gets the governance it actually needs.