AI-Powered Guides
CodeJam uses AI to dynamically generate comprehensive coding tutorials tailored to each topic in the platform. The system leverages OpenRouter and Google’s Gemini AI to create engaging, educational content on-demand.
Overview
The AI Guides integration provides:
Dynamic Generation : Tutorials are generated on-demand when first requested
Real-time Streaming : Content streams to users as it’s generated
Progress Tracking : Users’ progress through guides is tracked and rewarded
Campaign Integration : Guides unlock campaign nodes and award XP
Intelligent Caching : Once generated, guides are stored for instant retrieval
Architecture
The system spans three files:
convex/guides.ts - Public queries, mutations, and generation action
convex/internalGuides.ts - Internal database operations
Database schema with guides and guide_progress tables
Guide Generation
On-Demand Creation
When a guide is requested, the system follows this flow:
export const getOrGenerateGuide = action ({
args: {
slug: v . string (),
title: v . string (), // Context for AI if missing
category: v . string (), // Context for AI if missing
},
handler : async ( ctx , args ) => {
// 1. Check DB first
const existing = await ctx . runQuery ( internal . internalGuides . getGuideInternal , { slug: args . slug });
if ( existing ) {
return existing ;
}
// 2. Create Placeholder immediately
const guideId = await ctx . runMutation ( internal . internalGuides . saveGuide , {
slug: args . slug ,
title: args . title ,
category: args . category ,
content: "" ,
duration: "Calculating..." ,
});
// 3. Generate with AI...
},
});
The system creates a placeholder guide immediately, then streams AI-generated content. This ensures users see a loading state rather than waiting without feedback.
OpenRouter Integration
CodeJam uses OpenRouter to access Google’s Gemini 2.0 Flash model:
const apiKey = process . env . OPENROUTER_API_KEY || process . env . GEMINI_API_KEY ;
if ( ! apiKey ) {
throw new Error ( "No AI API Key configured (OPENROUTER_API_KEY)" );
}
const client = new OpenRouter ({
apiKey: apiKey ,
serverURL: process . env . OPENROUTER_API_URL ,
});
AI Prompt Structure
The system uses a structured prompt to ensure consistent, high-quality output:
const prompt = `
Write a comprehensive, engaging coding tutorial in Markdown format.
Topic: " ${ args . title } "
Language/Category: " ${ args . category } "
Target Audience: Developer bootcamp students.
Structure Requirements:
1. H1 Title
2. Introduction (Why this matters)
3. Core Concepts (H2)
4. Code Examples (Use markdown code blocks with language tags)
5. "Pro Tip" blockquote
6. Conclusion
Tone: High-energy, professional, clear.
Length: ~500 words.
` ;
Streaming Generation
Content is streamed in real-time to provide immediate feedback:
const stream = await client . chat . send ({
model: "google/gemini-2.0-flash-001" ,
messages: [
{ role: "system" , content: "You are an expert technical writer." },
{ role: "user" , content: prompt }
],
stream: true ,
});
let fullContent = "" ;
let chunkCount = 0 ;
let lastUpdate = Date . now ();
for await ( const chunk of stream ) {
const text = chunk . choices [ 0 ]?. delta ?. content || "" ;
fullContent += text ;
chunkCount ++ ;
// Update DB frequency: Every 20 chunks OR every 1 second
const now = Date . now ();
if ( chunkCount % 20 === 0 || ( now - lastUpdate > 1000 )) {
try {
await ctx . runMutation ( internal . internalGuides . updateGuide , {
id: guideId ,
content: fullContent
});
lastUpdate = now ;
} catch ( err ) {
console . error ( "Stream update failed (ignoring):" , err );
}
}
}
The system updates the database incrementally during streaming (every 20 chunks or 1 second) to prevent conflict errors from rapid mutations.
Reading Time Calculation
After generation completes, the system calculates an estimated reading time:
const duration = Math . ceil ( fullContent . split ( ' ' ). length / 200 ) + " min read" ;
await ctx . runMutation ( internal . internalGuides . updateGuide , {
id: guideId ,
content: fullContent ,
duration: duration
});
Progress Tracking
Database Schema
User progress is tracked in the guide_progress table:
guide_progress : defineTable ({
userId: v . id ( "users" ),
guideId: v . id ( "guides" ),
progress: v . number (), // 0-100
lastAccessed: v . number (),
completed: v . boolean (),
}). index ( "by_user" , [ "userId" ])
. index ( "by_user_guide" , [ "userId" , "guideId" ])
. index ( "by_user_lastAccessed" , [ "userId" , "lastAccessed" ])
Starting a Guide
When a user opens a guide:
export const startGuide = mutation ({
args: { guideId: v . id ( "guides" ) },
handler : async ( ctx , args ) => {
const userId = await getAuthUserId ( ctx );
if ( ! userId ) throw new Error ( "Unauthorized" );
const existing = await ctx . db
. query ( "guide_progress" )
. withIndex ( "by_user_guide" , ( q ) => q . eq ( "userId" , userId ). eq ( "guideId" , args . guideId ))
. first ();
if ( ! existing ) {
await ctx . db . insert ( "guide_progress" , {
userId ,
guideId: args . guideId ,
progress: 0 ,
lastAccessed: Date . now (),
completed: false ,
});
} else {
await ctx . db . patch ( existing . _id , {
lastAccessed: Date . now (),
});
}
},
});
Completing a Guide
When a user finishes a guide, they earn XP and unlock campaign nodes:
export const completeGuide = mutation ({
args: { guideId: v . id ( "guides" ) },
handler : async ( ctx , args ) => {
const userId = await getAuthUserId ( ctx );
if ( ! userId ) throw new Error ( "Unauthorized" );
const existing = await ctx . db
. query ( "guide_progress" )
. withIndex ( "by_user_guide" , ( q ) => q . eq ( "userId" , userId ). eq ( "guideId" , args . guideId ))
. first ();
if ( existing && ! existing . completed ) {
await ctx . db . patch ( existing . _id , {
completed: true ,
progress: 100 ,
lastAccessed: Date . now (),
});
// Award XP
await ctx . runMutation ( internal . activity . logActivityInternal , {
userId ,
type: "Guide Completion" ,
xp: 150 , // Big reward for finishing a guide
});
// Update campaign progress
const guide = await ctx . db . get ( args . guideId );
if ( guide ) {
const allNodes = await ctx . db . query ( "campaign_nodes" ). collect ();
const node = allNodes . find ( n => n . data ?. guideId === guide . slug );
if ( node ) {
await ctx . runMutation ( internal . campaign . internalCompleteNode , {
userId ,
nodeSlug: node . slug
});
}
}
}
},
});
Completing a guide awards 150 XP and can unlock new campaign nodes if the guide is part of a campaign briefing.
Querying Guides
Get Active Guide
Retrieve the user’s most recently accessed guide:
export const getActiveGuide = query ({
args: {},
handler : async ( ctx ) => {
const userId = await getAuthUserId ( ctx );
if ( ! userId ) return null ;
const progress = await ctx . db
. query ( "guide_progress" )
. withIndex ( "by_user_lastAccessed" , ( q ) => q . eq ( "userId" , userId ))
. order ( "desc" )
. first ();
if ( ! progress ) return null ;
const guide = await ctx . db . get ( progress . guideId );
if ( ! guide ) return null ;
return {
... guide ,
progress: progress . progress ,
completed: progress . completed ,
};
},
});
Get Guide by Slug
Fast lookup for specific guides:
export const getGuide = query ({
args: { slug: v . string () },
handler : async ( ctx , args ) => {
return await ctx . db
. query ( "guides" )
. withIndex ( "by_slug" , ( q ) => q . eq ( "slug" , args . slug ))
. first ();
},
});
Internal Mutations
Internal mutations handle database operations during generation:
export const saveGuide = internalMutation ({
args: {
slug: v . string (),
title: v . string (),
category: v . string (),
content: v . string (),
duration: v . string (),
},
handler : async ( ctx , args ) => {
// Check exist again to prevent race conditions
const existing = await ctx . db
. query ( "guides" )
. withIndex ( "by_slug" , ( q ) => q . eq ( "slug" , args . slug ))
. first ();
if ( ! existing ) {
return await ctx . db . insert ( "guides" , args );
}
return existing . _id ;
},
});
Configuration
Required environment variables:
OPENROUTER_API_KEY = your_openrouter_key
OPENROUTER_API_URL = https://openrouter.ai/api/v1 # Optional
# OR
GEMINI_API_KEY = your_gemini_key # Fallback
At least one of OPENROUTER_API_KEY or GEMINI_API_KEY must be configured for guide generation to work.
Usage Flow
User navigates to guide
User clicks on a briefing node in the campaign or searches for a guide.
Check cache
The system checks if the guide already exists in the database.
Generate if missing
If not cached, a placeholder is created and AI generation begins.
Stream content
Generated content streams in chunks, updating the database incrementally.
Track progress
As the user reads, their progress is tracked. Completion awards XP and unlocks campaign nodes.
Benefits
Scalable Content Generate unlimited tutorials without manual content creation
Instant Updates Content can be regenerated to reflect new best practices
Personalized Learning Guides can be tailored to specific topics and skill levels
Cost Effective Only generate content when requested, reducing API costs
Error Handling
The system gracefully handles failures:
try {
// Stream and generate content
} catch ( e ) {
console . error ( "Guide Gen Error" , e );
// Even if failed, we return what we have or null
return null ;
}
If generation fails:
The placeholder guide remains in the database
Users see an error state
The system can retry generation on the next request
Next Steps
Campaign System Learn how guides integrate with campaign progression
XP & Leveling Understand the XP system and rewards