What You’ll Build
An intelligent agent that can:- Store documents in an encrypted vault with automatic OCR and embedding generation
- Answer questions by retrieving relevant document chunks and synthesizing responses
- Add knowledge dynamically as users provide new information
- Cite sources with page numbers and document references
Why RAG?
Large Language Models are powerful, but they can only reason on their training data. RAG solves this by:- Embedding your documents into a searchable vector space
- Retrieving relevant chunks when a user asks a question
- Augmenting the LLM’s context with those chunks
- Generating an accurate, grounded response
Architecture
Prerequisites
- Case.dev API key (get one here)
- Node.js 18+ or Python 3.9+
- Vercel AI SDK (optional, for streaming UI)
Project Setup
Step 1: Install dependencies
# No installation needed — just set your API key
export CASEDEV_API_KEY="sk_case_YOUR_API_KEY"
brew tap CaseMark/casedev && brew install casedev
npm install casedev ai zod
pip install casedev
go get github.com/CaseMark/casedev-go
Step 2: Set up environment variables
Environment
CASEDEV_API_KEY=sk_case_your_api_key
Step 3: Create a vault for your knowledge base
curl -X POST https://api.case.dev/vault \
-H "Authorization: Bearer $CASEDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Knowledge Base",
"description": "Document intelligence agent knowledge store"
}'
casedev vault create \
--name "Knowledge Base" \
--description "Document intelligence agent knowledge store"
import Casedev from 'casedev';
const client = new Casedev({ apiKey: process.env.CASEDEV_API_KEY });
// Create a vault to store your knowledge base
const vault = await client.vault.create({
name: 'Knowledge Base',
description: 'Document intelligence agent knowledge store'
});
console.log(`Vault created: ${vault.id}`);
// Save this ID - you'll need it for queries
import os
import casedev
client = casedev.Casedev(api_key=os.environ['CASEDEV_API_KEY'])
# Create a vault to store your knowledge base
vault = client.vault.create(
name='Knowledge Base',
description='Document intelligence agent knowledge store'
)
print(f'Vault created: {vault.id}')
# Save this ID - you'll need it for queries
vault, _ := client.Vault.New(ctx, casedev.VaultNewParams{
Name: casedev.F("Knowledge Base"),
Description: casedev.F("Document intelligence agent knowledge store"),
})
fmt.Printf("Vault created: %s\n", vault.ID)
Core Functions
1. Add Documents to Knowledge Base
When a user uploads a document or provides information, store it in the vault:casedev vault upload \
--id $VAULT_ID \
--filename "document.pdf" \
--content-type "application/pdf"
import Casedev from 'casedev';
const client = new Casedev({ apiKey: process.env.CASEDEV_API_KEY });
const VAULT_ID = process.env.VAULT_ID;
/**
* Add a document or text to the knowledge base
*/
async function addToKnowledgeBase(content: string, metadata?: Record<string, string>) {
// For text content, create a text file
const blob = new Blob([content], { type: 'text/plain' });
const filename = `knowledge-${Date.now()}.txt`;
// Get upload URL
const upload = await client.vault.upload(VAULT_ID, {
filename,
contentType: 'text/plain',
metadata: {
source: 'user-input',
timestamp: new Date().toISOString(),
...metadata
}
});
// Upload content
await fetch(upload.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'text/plain' },
body: content
});
// Trigger ingestion (generates embeddings automatically)
await client.vault.ingest(VAULT_ID, upload.objectId);
return { objectId: upload.objectId, filename };
}
/**
* Add a file (PDF, Word, image) to the knowledge base
*/
async function addFileToKnowledgeBase(
file: Buffer,
filename: string,
contentType: string
) {
const upload = await client.vault.upload(VAULT_ID, {
filename,
contentType,
metadata: {
source: 'file-upload',
timestamp: new Date().toISOString()
}
});
await fetch(upload.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: file
});
// Ingestion handles OCR (if needed) and embedding generation
const job = await client.vault.ingest(VAULT_ID, upload.objectId);
return { objectId: upload.objectId, jobId: job.id };
}
import os
import requests
from datetime import datetime
import casedev
client = casedev.Casedev(api_key=os.environ['CASEDEV_API_KEY'])
VAULT_ID = os.environ['VAULT_ID']
def add_to_knowledge_base(content: str, metadata: dict = None):
"""Add text content to the knowledge base"""
filename = f'knowledge-{int(datetime.now().timestamp())}.txt'
# Get upload URL
upload = client.vault.upload(VAULT_ID,
filename=filename,
content_type='text/plain',
metadata={
'source': 'user-input',
'timestamp': datetime.now().isoformat(),
**(metadata or {})
}
)
# Upload content
requests.put(upload.upload_url,
data=content.encode('utf-8'),
headers={'Content-Type': 'text/plain'}
)
# Trigger ingestion (generates embeddings automatically)
client.vault.ingest(upload.object_id, id=VAULT_ID)
return {'object_id': upload.object_id, 'filename': filename}
def add_file_to_knowledge_base(file_path: str, content_type: str):
"""Add a file (PDF, Word, image) to the knowledge base"""
filename = os.path.basename(file_path)
upload = client.vault.upload(VAULT_ID,
filename=filename,
content_type=content_type,
metadata={
'source': 'file-upload',
'timestamp': datetime.now().isoformat()
}
)
with open(file_path, 'rb') as f:
requests.put(upload.upload_url, data=f,
headers={'Content-Type': content_type})
# Ingestion handles OCR (if needed) and embedding generation
job = client.vault.ingest(upload.object_id, id=VAULT_ID)
return {'object_id': upload.object_id, 'job_id': job.id}
upload, _ := client.Vault.Upload(ctx, vaultID, casedev.VaultUploadParams{
Filename: casedev.F("document.pdf"),
ContentType: casedev.F("application/pdf"),
})
// PUT file to upload.UploadURL via net/http
fmt.Println(upload.ObjectID)
2. Retrieve Relevant Information
Search the knowledge base for content relevant to a user’s question:casedev vault search \
--id $VAULT_ID \
--query "search query"
/**
* Find relevant content from the knowledge base
*/
async function findRelevantContent(query: string, limit: number = 5) {
const results = await client.vault.search(VAULT_ID, {
query,
method: 'hybrid', // Combines semantic + keyword search
limit
});
// Format results for LLM context
return results.chunks.map(chunk => ({
content: chunk.text,
source: chunk.filename,
page: chunk.page,
score: chunk.hybridScore
}));
}
def find_relevant_content(query: str, limit: int = 5):
"""Find relevant content from the knowledge base"""
results = client.vault.search(VAULT_ID,
query=query,
method='hybrid', # Combines semantic + keyword search
top_k=limit
)
# Format results for LLM context
return [{
'content': chunk.text,
'source': chunk.filename,
'page': chunk.page,
'score': chunk.hybrid_score
} for chunk in results.chunks]
results, _ := client.Vault.Search(ctx, vaultID, casedev.VaultSearchParams{
Query: casedev.F("search query"),
Method: casedev.F(casedev.VaultSearchParamsMethodHybrid),
})
for _, chunk := range results.Chunks {
fmt.Println(chunk.Text)
}
3. Generate Responses with Context
Use the LLM Gateway to generate responses grounded in your documents:# Search knowledge base
casedev vault search --id $VAULT_ID \
--query "What are the key terms?" --method hybrid --limit 10
# Answer with LLM
casedev llm:v1:chat create-completion \
--model anthropic/claude-sonnet-4.5 \
--message '{role: system, content: "Answer using only the provided context. Cite sources."}' \
--message '{role: user, content: "Context: <search results>\n\nQuestion: What are the key terms?"}' \
--temperature 0.3 --max-tokens 1000
/**
* Answer a question using the knowledge base
*/
async function answerQuestion(question: string) {
// 1. Retrieve relevant context
const relevantContent = await findRelevantContent(question);
if (relevantContent.length === 0) {
return {
answer: "I don't have any information about that in my knowledge base.",
sources: []
};
}
// 2. Format context for the LLM
const context = relevantContent
.map((c, i) => `[${i + 1}] ${c.content} (Source: ${c.source}, Page ${c.page})`)
.join('\n\n');
// 3. Generate response with LLM
const response = await client.llm.v1.chat.createCompletion({
model: 'anthropic/claude-sonnet-4.5',
messages: [
{
role: 'system',
content: `You are a helpful assistant that answers questions based on the provided context.
Rules:
- Only use information from the provided context
- Cite sources using [1], [2], etc.
- If the context doesn't contain relevant information, say so
- Be concise and accurate`
},
{
role: 'user',
content: `Context:\n${context}\n\nQuestion: ${question}`
}
],
temperature: 0.3,
max_tokens: 1000
});
return {
answer: response.choices[0].message.content,
sources: relevantContent.map(c => ({
filename: c.source,
page: c.page,
excerpt: c.content.substring(0, 200) + '...'
}))
};
}
def answer_question(question: str):
"""Answer a question using the knowledge base"""
# 1. Retrieve relevant context
relevant_content = find_relevant_content(question)
if not relevant_content:
return {
'answer': "I don't have any information about that in my knowledge base.",
'sources': []
}
# 2. Format context for the LLM
context = '\n\n'.join([
f"[{i+1}] {c['content']} (Source: {c['source']}, Page {c['page']})"
for i, c in enumerate(relevant_content)
])
# 3. Generate response with LLM
response = client.llm.v1.chat.create_completion(
model='anthropic/claude-sonnet-4.5',
messages=[
{
'role': 'system',
'content': '''You are a helpful assistant that answers questions based on the provided context.
Rules:
- Only use information from the provided context
- Cite sources using [1], [2], etc.
- If the context doesn't contain relevant information, say so
- Be concise and accurate'''
},
{
'role': 'user',
'content': f'Context:\n{context}\n\nQuestion: {question}'
}
],
temperature=0.3,
max_tokens=1000
)
return {
'answer': response.choices[0].message.content,
'sources': [{
'filename': c['source'],
'page': c['page'],
'excerpt': c['content'][:200] + '...'
} for c in relevant_content]
}
// 1. Retrieve relevant context from vault
results, _ := client.Vault.Search(ctx, vaultID, casedev.VaultSearchParams{
Query: casedev.F(question),
Method: casedev.F(casedev.VaultSearchParamsMethodHybrid),
TopK: casedev.F(int64(10)),
})
// Format context
var context string
for i, c := range results.Chunks {
context += fmt.Sprintf("[%d] %s (Source: %s)\n\n", i+1, c.Text, c.Filename)
}
// 2. Generate answer with LLM
resp, _ := client.Llm.V1.Chat.NewCompletion(ctx, casedev.LlmV1ChatNewCompletionParams{
Model: casedev.F("anthropic/claude-sonnet-4.5"),
Messages: casedev.F([]casedev.LlmV1ChatNewCompletionParamsMessage{
{
Role: casedev.F(casedev.LlmV1ChatNewCompletionParamsMessagesRoleSystem),
Content: casedev.F("You are a helpful assistant. Answer using only the provided context. Cite sources using [1], [2], etc."),
},
{
Role: casedev.F(casedev.LlmV1ChatNewCompletionParamsMessagesRoleUser),
Content: casedev.F("Context:\n" + context + "\n\nQuestion: " + question),
},
}),
Temperature: casedev.F(float64(0.3)),
MaxTokens: casedev.F(int64(1000)),
})
fmt.Println(resp.Choices[0].Message.Content)
Building the Agent with Tools
For a more sophisticated agent that can decide when to search vs. add knowledge, use tool calling:# Tool-calling agents require programmatic control flow.
# Use the Go or TypeScript SDK for agent loops.
# For simple questions, pipe vault search into LLM:
casedev vault search --id $VAULT_ID --query "What deadlines are coming up?"
casedev llm:v1:chat create-completion \
--model anthropic/claude-sonnet-4.5 \
--message '{role: system, content: "You are a document intelligence agent."}' \
--message '{role: user, content: "Based on these documents: <search results>\n\nWhat deadlines are coming up?"}'
import { z } from 'zod';
// Define tools for the agent
const tools = {
searchKnowledgeBase: {
description: 'Search the knowledge base for information relevant to a question',
parameters: z.object({
query: z.string().describe('The search query')
}),
execute: async ({ query }) => {
const results = await findRelevantContent(query);
return results.length > 0
? results
: 'No relevant information found in knowledge base.';
}
},
addToKnowledgeBase: {
description: 'Add new information to the knowledge base. Use this when the user provides facts or documents.',
parameters: z.object({
content: z.string().describe('The content to add'),
topic: z.string().optional().describe('Topic or category')
}),
execute: async ({ content, topic }) => {
const result = await addToKnowledgeBase(content, { topic });
return `Added to knowledge base: ${result.filename}`;
}
}
};
/**
* Run the agent with tool support
*/
async function runAgent(userMessage: string, conversationHistory: any[] = []) {
const messages = [
{
role: 'system',
content: `You are a helpful document intelligence assistant.
Your capabilities:
1. Search your knowledge base to answer questions
2. Add new information when users provide it
Always search the knowledge base before answering factual questions.
If you don't find relevant information, say so honestly.
When adding information, confirm what was added.`
},
...conversationHistory,
{ role: 'user', content: userMessage }
];
// First call - may request tool use
let response = await client.llm.v1.chat.createCompletion({
model: 'anthropic/claude-sonnet-4.5',
messages,
tools: ,
}
tool_choice: 'auto'
});
// Handle tool calls
while (response.choices[0].message.tool_calls) {
const toolCalls = response.choices[0].message.tool_calls;
// Execute each tool
const toolResults = await Promise.all(
toolCalls.map(async (call) => {
const tool = tools[call.function.name];
const args = JSON.parse(call.function.arguments);
const result = await tool.execute(args);
return {
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(result)
};
})
);
// Continue conversation with tool results
messages.push(response.choices[0].message);
messages.push(...toolResults);
response = await client.llm.v1.chat.createCompletion({
model: 'anthropic/claude-sonnet-4.5',
messages,
tools: ,
}
}))
});
}
return response.choices[0].message.content;
}
import json
# Define tools for the agent
tools = [
{
'type': 'function',
'function': {
'name': 'search_knowledge_base',
'description': 'Search the knowledge base for information relevant to a question',
'parameters': {
'type': 'object',
'properties': {
'query': {
'type': 'string',
'description': 'The search query'
}
},
'required': ['query']
}
}
},
{
'type': 'function',
'function': {
'name': 'add_to_knowledge_base',
'description': 'Add new information to the knowledge base',
'parameters': {
'type': 'object',
'properties': {
'content': {
'type': 'string',
'description': 'The content to add'
},
'topic': {
'type': 'string',
'description': 'Topic or category'
}
},
'required': ['content']
}
}
}
]
def execute_tool(name: str, args: dict):
"""Execute a tool by name"""
if name == 'search_knowledge_base':
results = find_relevant_content(args['query'])
return results if results else 'No relevant information found.'
elif name == 'add_to_knowledge_base':
result = add_to_knowledge_base(args['content'], {'topic': args.get('topic', '')})
return f"Added to knowledge base: {result['filename']}"
def run_agent(user_message: str, conversation_history: list = None):
"""Run the agent with tool support"""
messages = [
{
'role': 'system',
'content': '''You are a helpful document intelligence assistant.
Your capabilities:
1. Search your knowledge base to answer questions
2. Add new information when users provide it
Always search the knowledge base before answering factual questions.
If you don't find relevant information, say so honestly.'''
},
*(conversation_history or []),
{'role': 'user', 'content': user_message}
]
# First call - may request tool use
response = client.llm.v1.chat.create_completion(
model='anthropic/claude-sonnet-4.5',
messages=messages
)
# Handle tool calls
while response.choices[0].message.tool_calls:
tool_calls = response.choices[0].message.tool_calls
# Add assistant message with tool calls
messages.append(response.choices[0].message)
# Execute each tool and add results
for call in tool_calls:
args = json.loads(call.function.arguments)
result = execute_tool(call.function.name, args)
messages.append({
'role': 'tool',
'tool_call_id': call.id,
'content': json.dumps(result)
})
# Continue conversation
response = client.llm.v1.chat.create_completion(
model='anthropic/claude-sonnet-4.5',
messages=messages
)
return response.choices[0].message.content
// Tool-calling agent loop in Go
// 1. Initial request with tools
resp, _ := client.Llm.V1.Chat.NewCompletion(ctx, casedev.LlmV1ChatNewCompletionParams{
Model: casedev.F("anthropic/claude-sonnet-4.5"),
Messages: casedev.F([]casedev.LlmV1ChatNewCompletionParamsMessage{
{
Role: casedev.F(casedev.LlmV1ChatNewCompletionParamsMessagesRoleSystem),
Content: casedev.F("You are a document intelligence agent with access to a knowledge base."),
},
{
Role: casedev.F(casedev.LlmV1ChatNewCompletionParamsMessagesRoleUser),
Content: casedev.F(question),
},
}),
// Tools defined as JSON schema (searchKnowledgeBase, addToKnowledgeBase)
})
// 2. If tool calls returned, execute them and continue
// See TypeScript example for full tool-calling loop pattern
fmt.Println(resp.Choices[0].Message.Content)
Integration with Vercel AI SDK
For Next.js applications, integrate with the Vercel AI SDK for streaming responses:Typescript
// app/api/chat/route.ts
import { streamText, tool } from 'ai';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { z } from 'zod';
import Casedev from 'casedev';
const client = new Casedev({ apiKey: process.env.CASEDEV_API_KEY });
const VAULT_ID = process.env.VAULT_ID;
// Set up Case.dev as an OpenAI-compatible provider for Vercel AI SDK
const casedev = createOpenAICompatible({
name: 'casedev',
baseURL: 'https://api.case.dev/llm/v1',
headers: { Authorization: `Bearer ${process.env.CASEDEV_API_KEY}` },
});
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: casedev('anthropic/claude-sonnet-4.5'),
system: `You are a document intelligence assistant.
Check your knowledge base before answering questions.
Only respond using information from tool calls.
If no relevant information is found, say "I don't have information about that."`,
messages,
maxSteps: 5,
tools: {
searchDocuments: tool({
description: 'Search the document knowledge base',
parameters: z.object({
query: z.string().describe('The search query')
}),
execute: async ({ query }) => {
const results = await client.vault.search(VAULT_ID, {
query,
method: 'hybrid',
limit: 5
});
return results.chunks.map(c => ({
text: c.text,
source: c.filename,
page: c.page
}));
}
}),
addDocument: tool({
description: 'Add information to the knowledge base',
parameters: z.object({
content: z.string().describe('Content to add')
}),
execute: async ({ content }) => {
const upload = await client.vault.upload(VAULT_ID, {
filename: `note-${Date.now()}.txt`,
contentType: 'text/plain'
});
await fetch(upload.uploadUrl, {
method: 'PUT',
body: content
});
await client.vault.ingest(VAULT_ID, upload.objectId);
return 'Added to knowledge base successfully';
}
})
}
});
return result.toDataStreamResponse();
}
Example Usage
Typescript
// Add some knowledge
await addToKnowledgeBase(
'The Smith v. Jones case was filed on March 15, 2024. The plaintiff alleges negligence in the maintenance of the property.',
{ topic: 'case-facts' }
);
await addToKnowledgeBase(
'Deposition of John Smith on April 2, 2024: Witness stated he observed water damage on the ceiling two weeks before the incident.',
{ topic: 'depositions' }
);
// Ask questions
const result = await answerQuestion('When was the Smith v. Jones case filed?');
console.log(result.answer);
// "The Smith v. Jones case was filed on March 15, 2024 [1]."
const result2 = await answerQuestion('What did John Smith observe?');
console.log(result2.answer);
// "John Smith observed water damage on the ceiling two weeks before the incident [1]."
// Using the agent
const response = await runAgent('My favorite pizza topping is pepperoni. Remember that.');
console.log(response);
// "I've added that to my knowledge base. Your favorite pizza topping is pepperoni."
const response2 = await runAgent('What is my favorite pizza topping?');
console.log(response2);
// "According to my knowledge base, your favorite pizza topping is pepperoni."
Best Practices
Chunking is automatic. Case.dev Vaults automatically chunk documents into semantic segments optimized for retrieval. You don’t need to implement chunking yourself.
1. Use hybrid search
Combine semantic and keyword search for best results:casedev vault search --id $VAULT_ID \
--query "liability insurance coverage limits" \
--method hybrid --limit 10
const results = await client.vault.search(VAULT_ID, {
query: 'liability insurance coverage limits',
method: 'hybrid', // Best of both worlds
limit: 10
});
results = client.vault.search(VAULT_ID,
query='liability insurance coverage limits',
method='hybrid', # Best of both worlds
top_k=10
)
results, _ := client.Vault.Search(ctx, vaultID, casedev.VaultSearchParams{
Query: casedev.F("liability insurance coverage limits"),
Method: casedev.F(casedev.VaultSearchParamsMethodHybrid),
TopK: casedev.F(int64(10)),
})
2. Set appropriate temperature
Use low temperature for factual retrieval:curl -X POST https://api.case.dev/llm/v1/chat/completions \
-H "Authorization: Bearer $CASEDEV_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'
casedev llm:v1:chat create-completion \
--model anthropic/claude-sonnet-4.5 \
--message '{role: user, content: "..."}' \
--temperature 0.2
const response = await client.llm.v1.chat.createCompletion({
model: 'anthropic/claude-sonnet-4.5',
messages: [...],
temperature: 0.2 // More deterministic for factual tasks
});
response = client.llm.v1.chat.create_completion(
model='anthropic/claude-sonnet-4.5',
messages=[...],
temperature=0.2 # More deterministic for factual tasks
)
resp, _ := client.Llm.V1.Chat.NewCompletion(ctx, casedev.LlmV1ChatNewCompletionParams{
Model: casedev.F("anthropic/claude-sonnet-4.5"),
Messages: casedev.F(messages),
Temperature: casedev.F(float64(0.2)),
})
3. Structure your prompts
Be explicit about using only provided context:Typescript
const systemPrompt = `You are a legal research assistant.
Rules:
- ONLY use information from the provided context
- If information is not in the context, say "I don't have that information"
- Always cite sources using [1], [2], etc.
- Never make up or infer facts not explicitly stated`;
4. Handle no results gracefully
Typescript
const results = await findRelevantContent(query);
if (results.length === 0 || results[0].score < 0.5) {
return "I couldn't find relevant information in the knowledge base.";
}
Next Steps
- Add file upload support for PDFs and images
- Implement conversation memory with Vercel AI SDK
- Add citations with page links to your responses
- Scale with batch processing for large document sets

