Skip to main content

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

1

User navigates to guide

User clicks on a briefing node in the campaign or searches for a guide.
2

Check cache

The system checks if the guide already exists in the database.
3

Generate if missing

If not cached, a placeholder is created and AI generation begins.
4

Stream content

Generated content streams in chunks, updating the database incrementally.
5

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

Build docs developers (and LLMs) love