●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Building Production-Ready AI Apps with Claude API × Supabase — pgvector RAG, Realtime Sync & Row Level Security Integration Guide
Build production AI apps with Claude API and Supabase. Implement RAG with pgvector, multi-tenant RLS, and real-time streaming in one integrated architecture.
When indie developers and startups ship AI apps to production, the biggest challenge is building a scalable, secure backend quickly. Claude API delivers world-class language capabilities, but that alone doesn't make an app. You also need user data storage, vector search, real-time updates, and authentication — and Supabase handles all of this in a single platform.
Supabase is an open-source Firebase alternative built on PostgreSQL, providing:
Realtime: Push database changes to clients instantly
Row Level Security (RLS): Row-level access control for multi-tenant apps
Edge Functions: Deno-based serverless functions
Storage: File storage for RAG source materials like PDFs and images
This combination gives you the ideal architecture: Claude API's powerful reasoning × Supabase's robust infrastructure. This guide provides complete design patterns and production-quality code for this integration.
The target audience is intermediate-to-advanced developers who already understand Claude API basics and are looking for deeper production implementation patterns.
1. Designing the Overall Architecture
The system we'll build in this article is a knowledge base AI assistant. Users upload documents, ask questions in natural language, and Claude identifies relevant sections to answer — a classic RAG (Retrieval-Augmented Generation) architecture.
Edge Functions: Secure design that never exposes API keys to the client
RLS: Users can only access their own documents
pgvector: No dedicated vector DB needed, dramatically reducing costs
Realtime + Streaming: Stream answer generation in real time
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Master the complete implementation code for a high-accuracy RAG pipeline combining pgvector with Claude API
✦Understand multi-tenant AI app design patterns using Row Level Security × Claude API
✦Learn production-ready techniques for combining Supabase Realtime with Claude streaming
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Run the following in the Supabase Dashboard SQL Editor:
-- Enable pgvectorCREATE EXTENSION IF NOT EXISTS vector;-- Documents tableCREATE TABLE documents ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, -- Original text (for reference) embedding VECTOR(1536), -- Voyage AI embedding dimension metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW());-- Document chunks table (for splitting long documents)CREATE TABLE document_chunks ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, document_id UUID REFERENCES documents(id) ON DELETE CASCADE NOT NULL, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, chunk_index INTEGER NOT NULL, content TEXT NOT NULL, embedding VECTOR(1536), token_count INTEGER, created_at TIMESTAMPTZ DEFAULT NOW());-- Index for vector search (IVFFlat: recommended for large datasets)CREATE INDEX ON document_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);-- Chat history tablesCREATE TABLE chat_sessions ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, title TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW());CREATE TABLE chat_messages ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, session_id UUID REFERENCES chat_sessions(id) ON DELETE CASCADE NOT NULL, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL, role TEXT CHECK (role IN ('user', 'assistant')) NOT NULL, content TEXT NOT NULL, sources JSONB DEFAULT '[]', -- Referenced document chunks created_at TIMESTAMPTZ DEFAULT NOW());
2.2 Configuring Row Level Security
RLS is the foundation of multi-tenant design — each user can only read and write their own data.
-- RLS for documentsALTER TABLE documents ENABLE ROW LEVEL SECURITY;CREATE POLICY "users_own_documents" ON documents FOR ALL USING (auth.uid() = user_id);-- RLS for document_chunksALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY;CREATE POLICY "users_own_chunks" ON document_chunks FOR ALL USING (auth.uid() = user_id);-- RLS for chat_sessionsALTER TABLE chat_sessions ENABLE ROW LEVEL SECURITY;CREATE POLICY "users_own_sessions" ON chat_sessions FOR ALL USING (auth.uid() = user_id);-- RLS for chat_messagesALTER TABLE chat_messages ENABLE ROW LEVEL SECURITY;CREATE POLICY "users_own_messages" ON chat_messages FOR ALL USING (auth.uid() = user_id);
2.3 Defining the Vector Search Function
-- User-specific vector similarity search functionCREATE OR REPLACE FUNCTION match_chunks( query_embedding VECTOR(1536), target_user_id UUID, match_threshold FLOAT DEFAULT 0.7, match_count INT DEFAULT 5)RETURNS TABLE ( id UUID, document_id UUID, content TEXT, similarity FLOAT)LANGUAGE plpgsqlAS $$BEGIN RETURN QUERY SELECT dc.id, dc.document_id, dc.content, 1 - (dc.embedding <=> query_embedding) AS similarity FROM document_chunks dc WHERE dc.user_id = target_user_id AND 1 - (dc.embedding <=> query_embedding) > match_threshold ORDER BY dc.embedding <=> query_embedding LIMIT match_count;END;$$;
3. Building the Document Embedding Pipeline
3.1 Text Chunking Strategy
Splitting long documents into appropriately sized chunks directly impacts RAG accuracy. Rather than simple character-count splitting, we want to respect semantic boundaries.
// supabase/functions/embed-document/index.tsimport { serve } from "https://deno.land/std@0.208.0/http/server.ts";import { createClient } from "https://esm.sh/@supabase/supabase-js@2";import Anthropic from "https://esm.sh/@anthropic-ai/sdk@0.39.0";// Split text into semantically meaningful chunksfunction chunkText(text: string, maxTokens = 512, overlap = 64): string[] { // Prioritize paragraphs, headings, and list items as boundaries const paragraphs = text .split(/\n{2,}/) .map(p => p.trim()) .filter(p => p.length > 0); const chunks: string[] = []; let currentChunk = ""; let currentTokenEstimate = 0; for (const paragraph of paragraphs) { // Rough estimate: 1 token ≈ 4 characters in English const paragraphTokens = Math.ceil(paragraph.length / 4); if (currentTokenEstimate + paragraphTokens > maxTokens && currentChunk) { chunks.push(currentChunk.trim()); // Overlap: carry the tail of the previous chunk into the next const overlapText = currentChunk .split(/\s+/) .slice(-overlap) .join(" "); currentChunk = overlapText + "\n\n" + paragraph; currentTokenEstimate = Math.ceil(currentChunk.length / 4); } else { currentChunk += (currentChunk ? "\n\n" : "") + paragraph; currentTokenEstimate += paragraphTokens; } } if (currentChunk.trim()) { chunks.push(currentChunk.trim()); } return chunks;}serve(async (req) => { const { documentId, content, title } = await req.json(); const authHeader = req.headers.get("Authorization")!; const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, { global: { headers: { Authorization: authHeader } } } ); const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response("Unauthorized", { status: 401 }); const chunks = chunkText(content); console.log(`Document "${title}": ${chunks.length} chunks`); // Generate embeddings using Voyage AI (recommended by Anthropic for RAG) const embeddings = await Promise.all( chunks.map(async (chunk) => { const response = await fetch("https://api.voyageai.com/v1/embeddings", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${Deno.env.get("VOYAGE_API_KEY")}`, }, body: JSON.stringify({ input: chunk, model: "voyage-3", }), }); const data = await response.json(); return data.data[0].embedding as number[]; }) ); // Store chunks and embeddings in Supabase const chunkRecords = chunks.map((chunk, index) => ({ document_id: documentId, user_id: user.id, chunk_index: index, content: chunk, embedding: embeddings[index], token_count: Math.ceil(chunk.length / 4), })); const { error } = await supabase .from("document_chunks") .insert(chunkRecords); if (error) { return Response.json({ error: error.message }, { status: 500 }); } return Response.json({ success: true, chunksCreated: chunks.length });});
4. Implementing RAG Chat with Claude API × pgvector
4.1 Streaming-Enabled Chat Edge Function
// supabase/functions/chat/index.tsimport { serve } from "https://deno.land/std@0.208.0/http/server.ts";import { createClient } from "https://esm.sh/@supabase/supabase-js@2";import Anthropic from "https://esm.sh/@anthropic-ai/sdk@0.39.0";const anthropic = new Anthropic({ apiKey: Deno.env.get("ANTHROPIC_API_KEY")!,});serve(async (req) => { const { sessionId, message } = await req.json(); const authHeader = req.headers.get("Authorization")!; const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, { global: { headers: { Authorization: authHeader } } } ); const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response("Unauthorized", { status: 401 }); // Step 1: Embed the user's question const queryEmbedding = await getEmbedding(message); // Step 2: Retrieve relevant chunks from pgvector const { data: relevantChunks } = await supabase.rpc("match_chunks", { query_embedding: queryEmbedding, target_user_id: user.id, match_threshold: 0.70, match_count: 6, }); // Step 3: Build context string const context = relevantChunks ?.map((chunk, i) => `[Source ${i + 1}]\n${chunk.content}`) .join("\n\n---\n\n") ?? "No relevant documents found."; // Step 4: Fetch conversation history const { data: history } = await supabase .from("chat_messages") .select("role, content") .eq("session_id", sessionId) .order("created_at", { ascending: true }) .limit(10); const messages: Anthropic.MessageParam[] = [ ...(history ?? []).map(m => ({ role: m.role as "user" | "assistant", content: m.content, })), { role: "user", content: message }, ]; // Step 5: Stream from Claude API const stream = anthropic.messages.stream({ model: "claude-sonnet-4-6", max_tokens: 2048, system: `You are an AI assistant that answers questions based exclusively on the provided documents.Rules to strictly follow:- Only answer based on the context (documents) provided below- If the answer is not in the documents, say "That information is not available in the provided documents"- Always cite the source number when referencing information (e.g., [Source 1])- Never mix in speculation or external knowledge## Reference Documents${context}`, messages, }); // Step 6: Forward streaming response to client via SSE const encoder = new TextEncoder(); let fullContent = ""; const readable = new ReadableStream({ async start(controller) { try { for await (const event of stream) { if ( event.type === "content_block_delta" && event.delta.type === "text_delta" ) { const text = event.delta.text; fullContent += text; controller.enqueue( encoder.encode(`data: ${JSON.stringify({ text })}\n\n`) ); } else if (event.type === "message_stop") { await saveMessages(supabase, sessionId, user.id, message, fullContent, relevantChunks); controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.close(); } } } catch (error) { controller.error(error); } }, }); return new Response(readable, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", }, });});async function saveMessages( supabase: ReturnType<typeof createClient>, sessionId: string, userId: string, userMessage: string, assistantMessage: string, sources: any[]) { await supabase.from("chat_messages").insert([ { session_id: sessionId, user_id: userId, role: "user", content: userMessage }, { session_id: sessionId, user_id: userId, role: "assistant", content: assistantMessage, sources: sources?.map(s => ({ id: s.id, document_id: s.document_id, similarity: s.similarity, excerpt: s.content.slice(0, 200), })) ?? [], }, ]); await supabase .from("chat_sessions") .update({ updated_at: new Date().toISOString() }) .eq("id", sessionId);}async function getEmbedding(text: string): Promise<number[]> { const response = await fetch("https://api.voyageai.com/v1/embeddings", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${Deno.env.get("VOYAGE_API_KEY")}`, }, body: JSON.stringify({ input: text, model: "voyage-3" }), }); const data = await response.json(); return data.data[0].embedding;}
5. Building a Real-Time UI with Supabase Realtime
With Supabase Realtime, you can receive push notifications when streaming responses are saved to the database — enabling multi-tab synchronization and collaborative features.
6. Completing Multi-Tenant Design with Row Level Security × Claude API
6.1 Separating Internal Operations with Service Role
Inside Edge Functions, you can maintain the user's RLS context while selectively using the service role (admin access) for administrative operations — respecting RLS while still supporting management features.
// supabase/functions/admin-analytics/index.ts// ⚠️ This function is only callable by administratorsimport { serve } from "https://deno.land/std@0.208.0/http/server.ts";import { createClient } from "https://esm.sh/@supabase/supabase-js@2";serve(async (req) => { const adminSecret = req.headers.get("x-admin-secret"); if (adminSecret !== Deno.env.get("ADMIN_SECRET")) { return new Response("Forbidden", { status: 403 }); } // Service role client (bypasses RLS) const adminSupabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! // ⚠️ Never expose this to clients ); const { data: stats } = await adminSupabase .from("document_chunks") .select("user_id", { count: "exact" }); return Response.json({ totalChunks: stats?.length });});
6.2 Integrating RLS Context into Claude API System Prompts
Telling Claude which documents a user can access enables tighter control over responses.
async function buildSecureSystemPrompt( supabase: ReturnType<typeof createClient>, userId: string): Promise<string> { // RLS automatically filters to this user's documents const { data: documents } = await supabase .from("documents") .select("id, title, created_at") .eq("user_id", userId) .order("created_at", { ascending: false }) .limit(20); const docList = documents ?.map(d => `- ${d.title} (ID: ${d.id})`) .join("\n") ?? "None"; return `You are a dedicated AI assistant with access only to the following documents.## Accessible Documents${docList}## Critical Rules1. Only use information from the documents listed above2. If the answer is not in these documents, say "This is not covered in your documents"3. Never reference other users' data or external information4. Do not infer or extrapolate personal or confidential information`;}
7. Optimizing Costs with Prompt Caching
For large document collections, Prompt Caching is the key to cost reduction. When repeatedly sending the same system prompt or reference documents, you can reduce costs by up to 90%.
// Chat implementation leveraging prompt cachingasync function createCachedChatRequest( context: string, messages: Anthropic.MessageParam[]): Promise<Anthropic.Message> { return anthropic.messages.create({ model: "claude-sonnet-4-6", max_tokens: 2048, system: [ { type: "text", text: "You are an AI assistant that answers questions based on provided documents.", }, { type: "text", text: context, // Cache breakpoint: mark this block for caching cache_control: { type: "ephemeral" }, }, ], messages, });}// Cost estimate (e.g., 10,000 Q&A interactions):// Without caching: 1,000 tokens × 10,000 = 10M input tokens// → Claude Sonnet 4.6: $3.00/M tokens = $30.00// With caching (90% hit rate):// Cache write 1,000 × 1,000 = 1M tokens ($3.75)// Cache read 1,000 × 9,000 = 9M tokens ($0.27)// → Total $4.02 (~87% savings)
8. Production Monitoring and Performance Tuning
8.1 Optimizing pgvector Index Performance
-- Choose index type based on data scale-- Under 100K rows: HNSW (fastest queries, higher memory)CREATE INDEX ON document_chunks USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);-- Over 100K rows: IVFFlat (balanced)-- Recommended: lists = sqrt(row_count)CREATE INDEX ON document_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 316); -- sqrt(100000) ≈ 316-- Tune search parameters (accuracy vs. speed tradeoff)SET ivfflat.probes = 10; -- Default is 1; higher = more accurate but slower
Q1. Should I use pgvector or a dedicated vector database like Pinecone?
It depends on your scale and requirements. For under 10 million vectors, pgvector is excellent value — it integrates seamlessly with your existing Supabase infrastructure and eliminates a separate service to manage. For 100 million+ vectors or use cases where ANN response latency is paramount, dedicated solutions like Pinecone are more appropriate.
Q2. Why use Voyage AI embeddings?
Anthropic officially recommends Voyage AI embeddings for RAG use cases. The voyage-3 model delivers strong accuracy across multilingual text including English and Japanese, and produces the best retrieval quality when paired with Claude. Pricing is also competitive at $0.06 per 1M tokens (as of April 2026).
Q3. What should I do if Edge Functions time out during streaming?
Supabase Edge Functions have a default timeout of 150 seconds. For long responses, consider: (1) limiting output length with max_tokens, (2) offloading long-running tasks to Supabase Queue for async processing, or (3) extending the timeout in Supabase settings (Pro plan and above required).
Q4. How can I improve RAG accuracy for longer documents?
Use a chunk overlap of 10-15% between adjacent chunks to preserve contextual continuity across boundaries. Also tune the match_threshold parameter — too high and you miss relevant context, too low and noise increases. A starting range of 0.65-0.75 works well for most use cases. Monitor retrieval quality with logging over time and adjust accordingly.
Q5. How do I test and debug with RLS enabled?
The most reliable approach is to grab an actual user JWT from the Supabase Dashboard Authentication tab and test with curl or Postman. In the SQL Editor, you can also simulate a specific user by running SET role = authenticated; SET request.jwt.claim.sub = 'user-id'; before your query — this lets you test RLS behavior directly.
Summary
In this guide, we've covered how to build a production-grade AI app combining Claude API and Supabase. Key takeaways:
pgvector: Build RAG inside PostgreSQL without a dedicated vector database
Row Level Security: Multi-tenant data isolation enforced at the SQL layer, preventing application-level bugs
Supabase Realtime: Reliably synchronize final messages after streaming completes
Edge Functions: Call Claude API securely without exposing keys to clients
Prompt Caching: Cache repeated context for up to 90% cost reduction
This architecture scales gracefully from personal projects to tens of thousands of users in production. Start on Supabase's free tier, then upgrade to Pro as your user base grows — a genuinely sustainable path to shipping production AI apps.
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.