DEV Community

Miracleio
Miracleio Subscriber

Posted on AI-assisted

How Sanity Context & MCP Supercharged AI Customer Support in Basegent & Bucket Space

Sanity Challenge Path One Submission

This is a submission for the Sanity Challenge, Path One: Ship an Agent That Queries Real Content


What I Built

Over the past few months, I have been building Basegent, a modern AI customer support and operations platform. Basegent gives developers and SaaS teams an intelligent chat assistant that can resolve real customer problems, cite official documentation, and escalate smoothly to human operators when confidence drops.

At the same time, I run Bucket Space—a modern web application built for smart bookmarking, content capture, and personal library organization.

Naturally, I wanted Bucket to be the proving ground for Basegent. But as anyone who has deployed AI support bots in production knows, traditional Retrieval-Augmented Generation (RAG) has a dirty secret:

Traditional vector search treats company documentation like a bag of text chunks. It slices policies into arbitrary paragraphs, calculates similarity embeddings, and hopes the LLM can resolve contradictions on the fly.

In real-world customer support, this naive approach fails catastrophically:

  1. Temporal & Legal Precedence: If your company updated its refund policy from 14 days to 30 days in 2026, both paragraphs look semantically identical to a vector database. The bot will inevitably quote the outdated policy to an angry customer.
  2. Multi-Dimensional Entitlements: Support rules depend on customer attributes—plan tier (Free vs. Pro), geographical market (US vs. EU), or deployment type (standard vs. custom). Similarity search has no concept of conditional logic.
  3. Hallucinations on Generic Product Names: When a user asks "How do I set up Bucket on my phone?", an ungrounded LLM often assumes you mean Amazon S3 buckets or Google Cloud Storage, fabricating non-existent CLI commands and broken download links.

When Sanity announced the Sanity Context MCP and Knowledge Base beta in Sanity Labs, it clicked immediately. Sanity wasn't just offering another vector database; it was treating context as a managed, structured, verifiable source of truth with built-in conflict resolution and an open protocol (Model Context Protocol - MCP).

I set out to connect the entire loop:

  1. Integrate Sanity into Basegent: Built a first-party SanityContextSourceAdapter directly into Basegent that talks to Sanity's hosted MCP endpoint, paired with a multi-provider Bring-Your-Own-Key (BYOK) inference engine.
  2. Set up Sanity Knowledge Base & Context MCP: Built a dedicated Sanity organization project for Bucket, modeled structured support policies, and used Sanity's issue detection system to catch and resolve real policy conflicts into durable standing instructions.
  3. Deploy to Production in Bucket Space: Embedded the @basegent/react chat widget into mybucket.space, passing authenticated user tokens so the agent can cross-reference verified user identity against live Sanity knowledge base entries.

Here is the story of how it works, how Sanity Context solved our hardest policy dilemmas, and how you can implement this pattern in your own stack.


Demo & Live Deployments

Live Production Deployments


The Architecture at a Glance

Instead of copying periodic data dumps or storing duplicate content, Basegent queries Sanity Context live during the chat conversation via MCP JSON-RPC:

[ Customer on Bucket Space (mybucket.space) ]
                     │
      Authenticated Query + Signed Token
                     ▼
       [ @basegent/react Chat Widget ]
                     │
                     ▼  SSE / WebSocket
    [ Basegent Platform (basegent.space) ]
                     │
       ┌─────────────┴──────────────────────────┐
       │ Multi-Provider BYOI / BYOK Engine      │
       │ (Groq / OpenAI / Anthropic / Google)   │
       └─────────────┬──────────────────────────┘
                     │
         1. Discover Outline (/initial-context)
         2. Select Virtual Paths
         3. Live MCP Tool Call (knowledge_base_read)
                     ▼
  [ Sanity Context MCP Endpoint (api.sanity.io) ]
                     │
   ┌─────────────────┴──────────────────────────┐
   │ Sanity Knowledge Base (kb3NuTkXw21o)        │
   │ - Reconciled Structured Policies           │
   │ - Durable Standing Instructions            │
   │ - Canonical Documentation Citations        │
   └────────────────────────────────────────────┘
                     │
                     ▼
[ Streamed Answer with Citations & 1-Click Human Escalation ]
Enter fullscreen mode Exit fullscreen mode

Process Breakdown: The 3 Core Pillars

Pillar 1: Modeling Content, Knowledge Base & Context MCP in Sanity

To power support for Bucket, I created a dedicated project in Sanity Labs under organization Miracle Onyenma (oL4FZOkGh) with project ID jk662cms and dataset production.

Creating the Bucket project in Sanity Labs

1. Designing Structured Support Policies

Support policies shouldn't be plain blobs of text. In Sanity Studio, we modeled supportPolicy documents with explicit schema constraints:

// schemas/supportPolicy.ts
import { defineType, defineField } from "sanity";

export const supportPolicy = defineType({
  name: "supportPolicy",
  title: "Support Policy & Guide",
  type: "document",
  fields: [
    defineField({ name: "policyKey", type: "string", title: "Policy Key" }),
    defineField({ name: "title", type: "string", title: "Title" }),
    defineField({ name: "claim", type: "text", title: "Core Claim / Rule" }),
    defineField({
      name: "appliesTo",
      type: "object",
      title: "Applies To",
      fields: [
        { name: "plans", type: "array", of: [{ type: "string" }] },
        { name: "markets", type: "array", of: [{ type: "string" }] },
        { name: "productIds", type: "array", of: [{ type: "string" }] },
      ],
    }),
    defineField({ name: "effectiveFrom", type: "datetime", title: "Effective From" }),
    defineField({ name: "effectiveUntil", type: "datetime", title: "Effective Until" }),
    defineField({ name: "priority", type: "number", title: "Precedence Priority (0-100)" }),
    defineField({
      name: "authority",
      type: "string",
      options: { list: ["canonical", "legacy", "advisory"] },
    }),
    defineField({ name: "supersedes", type: "reference", to: [{ type: "supportPolicy" }] }),
    defineField({ name: "sourceUrl", type: "url", title: "Canonical Source URL" }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

We populated this dataset with real Bucket documentation alongside intentional edge cases:

  • Canonical Pro US Policy: 30-day refund window for Pro users in the United States (priority: 100, authority: canonical, effective 2026).
  • Legacy Global Policy: 14-day refund window for general purchases (priority: 10, authority: legacy, expired end of 2025).
  • Custom Enterprise Exception: 7-day refund window for bespoke enterprise configurations (priority: 90).
  • Processing Window: 5–7 business days fulfillment timeline after approval.
  • Product Feature Guides: Authentic step-by-step setup guides, including the official Apple iOS Share Sheet shortcut with its exact iCloud installation link.

2. Compiling the Knowledge Base & Catching Real Conflicts

Next, in Sanity Context Lab, we built the Basegent Support Policies Knowledge Base (kb3NuTkXw21o).

This is where Sanity Context shines. Instead of silently averaging out conflicting statements, Sanity actively parsed the documents, analyzed the domain boundaries, and flagged critical ambiguities in the Issues Review dashboard.

Conflict 1: Whole-Knowledge-Base Contradiction

Sanity flagged a direct conflict between the custom deployment exception (7 days) and the legacy global rule (14 days) regarding custom products:

Sanity Context detecting conflicts across the whole knowledge base

Conflict 2: Refund Eligibility Window Overlaps

Sanity highlighted that while the new policy claims to supersede the legacy policy, its applicability was strictly scoped to the US Pro tier, leaving free and non-US tiers potentially ambiguous:

Reviewing the conflicting refund eligibility terms

The Fix: Durable Standing Instructions

With one click in the Sanity interface, we resolved the issue by selecting the source-backed canonical claim. Sanity compiled this decision into a durable standing instruction that automatically survives future dataset rebuilds!

Resolved conflict state with standing instruction applied

Once verified, we generated an organization-level Context Viewer token and pointed Sanity's hosted Context MCP endpoint (https://api.sanity.io/v1/context/organizations/oL4FZOkGh/mcp/context) to this Knowledge Base.


Pillar 2: Integrating Sanity as a First-Party Source in Basegent

In Basegent, every customer support environment lives in an isolated tenant called a Workspace. We created the dedicated Bucket workspace (slug: bucket) at basegent.space/Account:

Creating the Bucket workspace in Basegent

1. Connecting the Source

In Basegent's Sources dashboard (/sources/new), we added Sanity Context as a first-party knowledge source, supplying:

  • The Sanity Context MCP URL
  • The Knowledge Base ID (kb3NuTkXw21o)
  • The secure Sanity organization token (encrypted at rest, never leaked to the client)

2. Implementing the Sanity Context Source Adapter

Basegent's core runtime defines a provider-neutral ContentSourceAdapter. To query Sanity live, we implemented SanityContextSourceAdapter:

Step A: Dynamic Outline Discovery (/initial-context)

When Basegent compiles its retrieval registry, it asks Sanity Context for its virtual outline:

// lib/sources/sanity-context-adapter.ts
export interface SanityContextConfig {
  mcpUrl: string;
  knowledgeBaseId: string;
  organizationToken: string;
}

export async function fetchKnowledgeIndex(
  config: SanityContextConfig,
  connectionId: string,
): Promise<string> {
  const url = new URL(config.mcpUrl);
  url.pathname = `${url.pathname.replace(/\/$/, "")}/initial-context`;
  url.searchParams.set("mode", "knowledge_base");
  url.searchParams.set("knowledgeBases", config.knowledgeBaseId);

  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${config.organizationToken}` },
    signal: AbortSignal.timeout(15_000),
  });

  if (!response.ok) {
    throw new Error(`Sanity Context discovery failed with HTTP ${response.status}`);
  }

  const initialContext = await response.text();
  const entries = parseKnowledgeBaseEntries(initialContext, config.knowledgeBaseId);

  return [
    `# Sanity Context Knowledge Base (${config.knowledgeBaseId})`,
    "Available virtual outline paths for live retrieval:",
    ...entries.map(
      (entry) => `- sanity-context/${connectionId}/${config.knowledgeBaseId}/${entry.path}`,
    ),
  ].join("\n");
}
Enter fullscreen mode Exit fullscreen mode
Step B: Live Tool Invocation over MCP JSON-RPC

When a customer asks a question, Basegent selects relevant virtual paths and executes the knowledge_base_read tool live over HTTP:

export async function callKnowledgeBaseRead(
  config: SanityContextConfig,
  path: string,
): Promise<string> {
  const response = await fetch(config.mcpUrl, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${config.organizationToken}`,
      "Content-Type": "application/json",
      Accept: "application/json, text/event-stream",
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: `basegent-${Date.now()}-${path}`,
      method: "tools/call",
      params: {
        name: "knowledge_base_read",
        arguments: {
          knowledgeBase: config.knowledgeBaseId,
          paths: [path],
        },
      },
    }),
    signal: AbortSignal.timeout(20_000),
  });

  if (!response.ok) {
    throw new Error(`Sanity MCP call error: HTTP ${response.status}`);
  }

  const payload = await response.json();
  if (payload.error) throw new Error(payload.error.message);

  // Extract clean text content from the MCP response
  const textContent = payload.result?.content
    ?.filter((part: any) => part.type === "text")
    .map((part: any) => part.text)
    .join("\n");

  return textContent || JSON.stringify(payload.result?.structuredContent, null, 2);
}

// Query Sanity Context's native search engine to augment path candidates
export async function callKnowledgeBaseSearch(
  config: SanityContextConfig,
  query: string,
): Promise<string[]> {
  const response = await fetch(config.mcpUrl, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${config.organizationToken}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: `basegent-search-${Date.now()}`,
      method: "tools/call",
      params: {
        name: "knowledge_base_search",
        arguments: { knowledgeBase: config.knowledgeBaseId, query },
      },
    }),
  });
  const payload = await response.json();
  const text = payload.result?.content?.[0]?.text ?? "";
  const matches = [
    ...text.matchAll(/`([A-Za-z0-9][A-Za-z0-9._~/-]*)`(?:\s+\(score\s+([0-9.]+)\))?/g),
  ];
  return matches.map((m) => m[1]).filter(Boolean);
}
Enter fullscreen mode Exit fullscreen mode

3. Multi-Provider BYOI / BYOK Architecture

Customer support agents cannot afford downtime or regional API rate limits. Basegent features a Bring Your Own Intelligence (BYOI / BYOK) engine that lets teams connect API keys from multiple providers with automatic fallbacks:

// lib/ai/provider-resolver.ts
export const SUPPORTED_PROVIDERS = ["groq", "openai", "anthropic", "google"] as const;

export const PROVIDER_DEFAULT_MODELS = {
  groq: { normal: "openai/gpt-oss-120b", budget: "llama-3.3-70b-versatile" },
  openai: { normal: "gpt-4o", budget: "gpt-4o-mini" },
  anthropic: { normal: "claude-3-5-sonnet-latest", budget: "claude-3-5-haiku-latest" },
  google: { normal: "gemini-2.0-flash", budget: "gemini-1.5-flash" },
};

export async function executeWithFallback<T>(
  candidates: Array<{ provider: string; apiKey: string; modelId: string }>,
  action: (model: any) => Promise<T>,
): Promise<T> {
  let lastError: unknown;

  for (const candidate of candidates) {
    try {
      const model = initModel(candidate.provider, candidate.apiKey, candidate.modelId);
      return await action(model);
    } catch (err: any) {
      lastError = err;
      if (isRateLimitOrQuotaError(err)) {
        console.warn(`[Basegent AI] ${candidate.provider} exhausted, failing over...`);
        continue;
      }
      throw err;
    }
  }

  throw lastError ?? new Error("All configured AI providers failed.");
}
Enter fullscreen mode Exit fullscreen mode

Whether running lightning-fast inference on Groq, deep reasoning on Claude 3.5 Sonnet, or cost-efficient answers on Gemini 2.0 Flash, the underlying knowledge remains anchored in Sanity Context.


Pillar 3: Deploying into Production on Bucket Space (mybucket.space)

With Sanity and Basegent connected, the final step was integrating the support assistant into mybucket.space.

1. Installing Official NPM Packages

Basegent publishes pre-built React components and TypeScript clients directly to npm:

npm install @basegent/react @basegent/client
Enter fullscreen mode Exit fullscreen mode

2. Authenticating Customer Context

When a signed-in user opens the chat, Bucket issues an HMAC-signed customer token via an internal API route. This informs Basegent of the user's plan tier, market, and registration timestamp without exposing private customer data:

// components/shared/BasegentWidget.tsx
"use client";

import { useEffect, useState } from "react";
import { BasegentProvider, BasegentChat } from "@basegent/react";
import { useAuth } from "@/components/providers/auth-provider";

export function BasegentWidget() {
  const { user } = useAuth();
  const [customerToken, setCustomerToken] = useState<string | undefined>();

  useEffect(() => {
    if (!user || user.isAnonymous) return;

    // Retrieve signed JWT/HMAC token with verified plan & market attributes
    fetch("/api/support/basegent-token", { method: "POST" })
      .then((res) => res.json())
      .then((data) => setCustomerToken(data.token))
      .catch((err) => console.error("Could not sign Basegent token:", err));
  }, [user]);

  return (
    <BasegentProvider
      tenantId={process.env.NEXT_PUBLIC_BASEGENT_TENANT_ID!}
      apiBase="https://basegent.space"
      customerToken={customerToken}
    >
      <BasegentChat
        title="Bucket Support"
        placeholder="Ask about features, shortcuts, or refund policies..."
        className="bottom-20 sm:bottom-24"
        showFAB={false} // Hidden in favor of our custom mobile bar trigger
      />
    </BasegentProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

3. Responsive UI Integration

On mobile screens, standard floating chat bubbles frequently block critical bottom navigation actions. We integrated a custom support trigger into Bucket's MobileBar.tsx, dynamically hiding the trigger when the user scrolls to the footer credits to preserve UI polish across iPhone, Android, and desktop viewports.


Real-World Scenarios in Action

Scenario 1: Multi-Tier Policy Reconciliation & Conflict Resolution

The Customer's Situation:
A customer who has been on the Pro plan in the United States for 21 days submits this inquiry:

"I am on the Pro plan in the US and bought the standard product 21 days ago. Can I still request a full refund, and how long will it take?"

What happens behind the scenes:

  1. Live Retrieval: Basegent queries the Sanity Knowledge Base outline via /initial-context and calls knowledge_base_read on refund_eligibility/standard_windows and refund_processing.
  2. Precedence Application: Sanity's Knowledge Base notes that the canonical 2026 Pro-US policy (priority: 100) grants a 30-day window and supersedes the legacy 14-day rule.
  3. Identity-Aware Verification: Basegent cross-references the retrieved policy with the customer's verified identity token (plan: pro, market: US, purchase age: 21 days) and confirms they are eligible for a refund (21 < 30).
  4. Separation of Concerns: The agent distinguishes eligibility (30-day window) from fulfillment time (5–7 business days to process payments back to the credit card).
  5. Citations & Provenance: The output provides direct links back to https://mybucket.space/settings and offers a one-click button to escalate to a human agent.

Scenario 2: Grounded Technical Guide (iOS Share Sheet Shortcut)

The Customer's Inquiry:

"How do I set up the iOS shortcut to bookmark links from Safari?"

What happens behind the scenes:

  1. Structured Retrieval: Basegent reads entry policy.bucket.guide.ios_shortcut directly from Sanity Context.
  2. Authentic iCloud Installation Link: The agent returns the exact, verified Apple Shortcuts iCloud link: https://www.icloud.com/shortcuts/57316fdc574b4deb97f93b0ff322c685
  3. Step-by-Step Device Pairing: Instructs the user to generate an API key at https://mybucket.space/connect, configure the action sheet, and optionally assign the shortcut to the iPhone 15/16 Action Button.
  4. Zero Hallucination: Because the agent is grounded in Sanity's Knowledge Base, it never mistakes Bucket for AWS S3 or hallucinates third-party App Store utilities.

Engineering Gotchas & Production Hardening

No real-world deployment goes 100% smoothly on the first try. Putting Sanity Context and MCP through real customer workflows on Bucket Space surfaced three critical engineering insights:

1. The Retrieval Trap: Bare Outline Slugs vs. knowledge_base_search

When Basegent first queried Sanity's /initial-context, the outline returned a clean, token-efficient table of contents:

ai_features/ai_content_processing [core]
content_saving/desktop_and_mobile_methods [core]
plans_and_markets [core]
product_overview
refund_policy [core]
refund_processing [core]
Enter fullscreen mode Exit fullscreen mode

When a user asked "How do I set up the iOS shortcut?", our first-pass routing model evaluated the bare slug desktop_and_mobile_methods. Because the slug didn't explicitly mention "iOS" or "shortcut", the router selected product_overview instead. And because product_overview didn't contain shortcut setup steps, the agent honestly (and anti-hallucinatively) replied: "I don't have specific documentation in our knowledge base for setting up an iOS shortcut."

The document was right there in Sanity, but the outline alone didn't expose its deeper topics.

The Fix: We upgraded SanityContextSourceAdapter to a two-tier hybrid retrieval strategy:

  1. Rich Index Metadata: At index time, Basegent extracts and caches the entry titles and leading summary paragraphs from Sanity, giving the high-level routing LLM full visibility:
   - sanity-context/.../desktop_and_mobile_methods [core] — Desktop & Mobile Saving Methods: Supported save methods: browser extension (Ctrl+Shift+S), JavaScript bookmarklet, Windows global hotkeys via PowerToys/Raycast, drag-and-drop upload, and iOS Share Sheet via Apple Shortcut (iOS 16+, API Key or device code pairing).
Enter fullscreen mode Exit fullscreen mode
  1. Native MCP Search Tooling: Alongside index path selection, Basegent hooks directly into Sanity's native knowledge_base_search MCP tool. Running the user query through Sanity's search engine scored content_saving/desktop_and_mobile_methods at 13.55, automatically appending it to the candidate pool before calling knowledge_base_read.

Now, the agent instantly retrieves the exact guide, requirements, and verified iCloud link every time.

2. The Conversational Escalation Loop in Chat Widgets

In customer support widgets, detecting high-intent keywords (like "refund") allows the widget to proactively prompt:

"Would you like to speak with a human agent instead? [Yes, connect me] [No, ask the bot]"

However, a subtle bug emerged: when a visitor clicked "No, ask the bot", re-submitting the original question through the default send() pipeline tripped the same keyword check again! This caused an infinite prompt loop and duplicate user bubbles in the conversation.

The Fix: In @basegent/react (bumped to v0.2.9), we introduced a bypassKeywordCheck flag to send(), cleared active escalation offers on conversation reset, and ensured that user bubbles aren't duplicated when bypassing checks. The user can smoothly decline human escalation and let the AI answer immediately.

3. Streaming Fallback & Zero-Token Resilience

With multi-provider BYOK inference (Groq, Claude, OpenAI), occasional network hiccups or reasoning-model token formats can cause a token stream to terminate prematurely before emitting tokens.

Rather than failing into a generic error message, Basegent now intercepts empty streams and falls back to complete text generation (generateText) across candidate models and API keys before finalizing the turn. If an answer fails, it is transparently logged to the admin Review Queue for human oversight.


Standalone Verification Script

To verify live retrieval from a Sanity Context MCP endpoint, here is a self-contained Node.js script:

// test-sanity-retrieval.mjs
// Run with: node test-sanity-retrieval.mjs
const MCP_URL = "https://api.sanity.io/v1/context/organizations/oL4FZOkGh/mcp/context";
const KB_ID = "kb3NuTkXw21o";
const TOKEN = process.env.SANITY_CONTEXT_API_TOKEN;

async function run() {
  if (!TOKEN) {
    console.error("Please export SANITY_CONTEXT_API_TOKEN=<your_org_context_token>");
    process.exit(1);
  }

  console.log("1. Fetching Knowledge Base Outline via /initial-context...");
  const outlineRes = await fetch(
    `${MCP_URL}/initial-context?mode=knowledge_base&knowledgeBases=${KB_ID}`,
    { headers: { Authorization: `Bearer ${TOKEN}` } },
  );

  if (!outlineRes.ok) {
    throw new Error(`Failed to fetch initial context: ${outlineRes.status}`);
  }

  const outline = await outlineRes.text();
  console.log("Discovered Knowledge Base Outline:\n");
  console.log(outline.slice(0, 500), "...\n");

  console.log("2. Executing MCP tool call 'knowledge_base_read'...");
  const toolRes = await fetch(MCP_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: "verify-challenge-call",
      method: "tools/call",
      params: {
        name: "knowledge_base_read",
        arguments: {
          knowledgeBase: KB_ID,
          paths: ["refund_eligibility/standard_windows"],
        },
      },
    }),
  });

  const toolPayload = await toolRes.json();
  console.log("Sanity Knowledge Base Response:\n");
  console.log(toolPayload.result?.content?.[0]?.text);
}

run().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

What Sanity Context Changes for AI Support

Building this integration fundamentally changed how I view AI customer support:

  1. Content Creators Retain Control: Product and support leads don't want to fine-tune weights or write complex RAG prompt hacks. They want to edit structured fields in Sanity Studio, inspect conflicting claims in Sanity Labs, and click "Resolve" to create a durable instruction.
  2. The Power of the Model Context Protocol (MCP): Using MCP meant Basegent didn't have to invent proprietary polling or sync daemons. Sanity hosted the server, managed the embeddings, and served clean JSON-RPC tools (knowledge_base_read).
  3. No Vendor Lock-In: By decoupling the knowledge layer (Sanity Context MCP) from the reasoning layer (Basegent's multi-provider BYOK engine), our customers can toggle between Groq, Claude, OpenAI, and Gemini while maintaining an unwavering single source of truth.

If you are building an AI agent that touches real customers, stop slicing text into dumb chunks. Ground your agent in a real Sanity Knowledge Base—your customers (and your support team) will thank you.


Resources & Links

Top comments (0)