Agent skill
convex-fundamentals
Guide for Convex backend development fundamentals including function types (queries, mutations, actions), layered architecture, HTTP actions, and the core mental model. Use when building Convex backends, creating queries/mutations/actions, implementing HTTP webhooks, or understanding Convex's reactive data model. Activates for Convex project setup, function definition, API design, or backend architecture tasks.
Install this agent skill to your Project
npx add-skill https://github.com/fluid-tools/claude-skills/tree/main/skills/convex-fundamentals
SKILL.md
Convex Fundamentals for TypeScript Agents
Overview
Convex is a reactive backend-as-a-service that provides real-time data synchronization, ACID transactions, and TypeScript-first development. This skill covers the core mental model, function types, layered architecture patterns, and HTTP action implementation.
TypeScript: NEVER Use any Type
CRITICAL RULE: This codebase has @typescript-eslint/no-explicit-any enabled. Using any will cause build failures.
❌ WRONG:
function handleData(data: any) { ... }
const items: any[] = [];
✅ CORRECT:
function handleData(data: { id: string; name: string }) { ... }
const items: Doc<"items">[] = [];
When to Use This Skill
Use this skill when:
- Building Convex backend applications from scratch
- Creating queries, mutations, or actions
- Understanding the difference between function types
- Implementing layered architecture patterns
- Setting up HTTP webhooks with route handlers
- Migrating from other backends to Convex
- Understanding Convex's reactive data model
Philosophy: Why Convex Feels "Wrong" (And Why That's Right)
Convex intentionally constrains you. When something feels like an anti-pattern, it's usually Convex telling you to rethink the approach.
The Intentional Friction
| What feels wrong | What Convex is telling you |
|---|---|
"I can't use .filter() on queries" |
Define an index. Queries should be O(log n), not O(n). |
"Actions can't access ctx.db" |
Side effects and transactions don't mix. Route through mutations. |
| "My mutation keeps failing with OCC errors" | You're writing too often to the same document. Redesign your data model or use Workpool. |
"I can't call fetch() in a mutation" |
Mutations must be deterministic. Schedule an action instead. |
| "Queries re-run on every document change" | You're collecting too much data. Narrow your index or denormalize. |
| "I can't do joins" | Denormalize. Embed related data or use lookup tables. |
Core Invariants
- Mutations are ACID transactions — All writes succeed together or fail together.
- Queries are reactive — They re-run when any read document changes.
- Actions are non-transactional orchestration — No reactivity, no
ctx.db. Can do external I/O and must call queries/mutations viactx.run*. - Scheduling is atomic with mutations — If mutation fails, scheduled functions don't run.
Core Mental Model
Everything in Convex falls into these categories:
| Kind | Visibility | Purpose |
|---|---|---|
query |
public | Read from DB, reactive subscriptions |
mutation |
public | Write to DB, ACID transactions |
action |
public | External APIs, non-deterministic work |
httpAction |
public (HTTP) | Webhooks, third-party integrations |
internalQuery |
private | Read primitives for internal use |
internalMutation |
private | Write primitives for internal use |
internalAction |
private | Orchestration, external calls |
Rule: Export a thin public surface. All real logic lives in internal primitives.
internal*variants have identical execution semantics to their public counterparts. The only difference is visibility — internal functions appear oninternal.*, notapi.*, and cannot be called from clients.
Layered Architecture
Layer 1: Client Surface (Public API)
Only entrypoints live here. Thin wrappers that validate, auth-check, and delegate.
// convex/jobs.ts — PUBLIC SURFACE
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Public mutation: client entrypoint
export const startGeneration = mutation({
args: { prompt: v.string() },
returns: v.id("jobs"),
handler: async (ctx, args) => {
// 1. Auth check
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
// 2. Delegate to internal mutation
const jobId = await ctx.runMutation(internal.jobs.createJob, {
userId: identity.subject,
prompt: args.prompt,
});
// 3. Schedule background work
await ctx.scheduler.runAfter(0, internal.jobs.processJob, { jobId });
return jobId;
},
});
// Public query: reactive subscription
export const getJob = query({
args: { jobId: v.id("jobs") },
returns: v.union(
v.object({
_id: v.id("jobs"),
_creationTime: v.number(),
status: v.string(),
prompt: v.string(),
result: v.optional(v.string()),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});
Layer 2: Domain Logic (Internal Primitives)
Where the real logic lives. These are your backend's private API.
// convex/jobs.ts — INTERNAL PRIMITIVES (same file, different exports)
import { internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";
export const createJob = internalMutation({
args: {
userId: v.string(),
prompt: v.string(),
},
returns: v.id("jobs"),
handler: async (ctx, args) => {
return await ctx.db.insert("jobs", {
userId: args.userId,
prompt: args.prompt,
status: "pending",
});
},
});
export const markComplete = internalMutation({
args: {
jobId: v.id("jobs"),
result: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.jobId, {
status: "completed",
result: args.result,
});
return null;
},
});
export const markFailed = internalMutation({
args: {
jobId: v.id("jobs"),
error: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch(args.jobId, {
status: "failed",
error: args.error,
});
return null;
},
});
export const getById = internalQuery({
args: { jobId: v.id("jobs") },
returns: v.union(
v.object({
_id: v.id("jobs"),
_creationTime: v.number(),
userId: v.string(),
prompt: v.string(),
status: v.string(),
result: v.optional(v.string()),
error: v.optional(v.string()),
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.jobId);
},
});
Layer 3: Orchestration (Actions + Scheduler)
For multi-step, external, or long-running work.
// convex/jobs.ts — ORCHESTRATION
import { internalAction } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
export const processJob = internalAction({
args: { jobId: v.id("jobs") },
returns: v.null(),
handler: async (ctx, args) => {
// 1. Read current state
const job = await ctx.runQuery(internal.jobs.getById, {
jobId: args.jobId,
});
if (!job || job.status !== "pending") return null;
try {
// 2. External API call (non-deterministic)
const result = await fetch("https://api.example.com/generate", {
method: "POST",
body: JSON.stringify({ prompt: job.prompt }),
});
const data = await result.json();
// 3. Write result via mutation
await ctx.runMutation(internal.jobs.markComplete, {
jobId: args.jobId,
result: data.output,
});
} catch (e) {
await ctx.runMutation(internal.jobs.markFailed, {
jobId: args.jobId,
error: String(e),
});
}
return null;
},
});
HTTP Actions (Webhooks)
HTTP actions must be in convex/http.ts with httpRouter:
// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/webhooks/stripe",
method: "POST",
handler: httpAction(async (ctx, req) => {
// 1. Validate signature
const signature = req.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing signature", { status: 401 });
}
// 2. Parse body
const body = await req.text();
// 3. Delegate to internal mutation
await ctx.runMutation(internal.billing.handleStripeWebhook, {
signature,
body,
});
return new Response(null, { status: 200 });
}),
});
export default http;
Model Layer Pattern
Extract business logic into reusable model functions. Functions stay thin; models do the work.
Why Model Layer?
- Reuse — Same logic across queries, mutations, actions
- Testability — Pure functions, easy to unit test
- Separation — Auth checks in one place, business logic in another
- Type Safety —
QueryCtxandMutationCtxtypes for context
Pattern: Model Functions
// convex/model/users.ts — MODEL LAYER
import { QueryCtx, MutationCtx } from "../_generated/server";
import { Id } from "../_generated/dataModel";
// Read helper: reusable across queries
export async function getCurrentUser(ctx: QueryCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const user = await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.unique();
if (!user) throw new Error("User not found");
return user;
}
// Access control helper
export async function ensureHasAccess(
ctx: QueryCtx,
{ conversationId }: { conversationId: Id<"conversations"> }
) {
const user = await getCurrentUser(ctx);
const conversation = await ctx.db.get(conversationId);
if (!conversation || !conversation.members.includes(user._id)) {
throw new Error("Unauthorized");
}
return { user, conversation };
}
// Write helper: encapsulates business logic
export async function addMessage(
ctx: MutationCtx,
{
conversationId,
content,
}: { conversationId: Id<"conversations">; content: string }
) {
const { user } = await ensureHasAccess(ctx, { conversationId });
const messageId = await ctx.db.insert("messages", {
conversationId,
authorId: user._id,
content,
createdAt: Date.now(),
});
// Update denormalized lastMessageAt
await ctx.db.patch(conversationId, { lastMessageAt: Date.now() });
return messageId;
}
Pattern: Thin Function Wrappers
// convex/conversations.ts — THIN WRAPPERS
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import * as Conversations from "./model/conversations";
// Query: delegates to model
export const listMessages = query({
args: { conversationId: v.id("conversations") },
returns: v.array(
v.object({
_id: v.id("messages"),
content: v.string(),
authorId: v.id("users"),
createdAt: v.number(),
})
),
handler: async (ctx, args) => {
return Conversations.listMessages(ctx, args);
},
});
// Mutation: delegates to model
export const sendMessage = mutation({
args: { conversationId: v.id("conversations"), content: v.string() },
returns: v.id("messages"),
handler: async (ctx, args) => {
return Conversations.addMessage(ctx, args);
},
});
File Structure
Flat-first. Convex uses file-based routing:
convex/
schema.ts # Database schema
http.ts # HTTP actions (webhooks)
auth.ts # Auth helpers
crons.ts # Cron job definitions
# Domain files (public + internal in same file)
users.ts # api.users.* + internal.users.*
jobs.ts
billing.ts
audio.ts
# Model layer (business logic)
model/
users.ts # User business logic helpers
conversations.ts # Conversation logic
billing.ts # Billing logic
# Shared utilities (NOT Convex functions)
_lib/
validators.ts # Shared validators
constants.ts # Constants
Routing:
convex/users.ts→api.users.functionName/internal.users.functionNameconvex/audio/stems.ts→api.audio.stems.functionNameconvex/model/*.ts→ NOT routed (helper imports only)
Function Types Quick Reference
Import Patterns
// Public (client-callable)
import { query, mutation, action } from "./_generated/server";
// Private (internal only)
import {
internalQuery,
internalMutation,
internalAction,
} from "./_generated/server";
// HTTP
import { httpAction } from "./_generated/server";
import { httpRouter } from "convex/server";
Function References
import { api, internal } from "./_generated/api";
// Public: api.fileName.functionName
await ctx.runQuery(api.users.getUser, { userId });
// Internal: internal.fileName.functionName
await ctx.runMutation(internal.jobs.createJob, { data });
Database Operations
// Read
const doc = await ctx.db.get(id);
const docs = await ctx.db
.query("table")
.withIndex("by_x", (q) => q.eq("x", value))
.collect();
const single = await ctx.db
.query("table")
.withIndex("by_x", (q) => q.eq("x", value))
.unique();
// Write
const id = await ctx.db.insert("table", { field: value });
await ctx.db.patch(id, { field: newValue });
await ctx.db.replace(id, { ...fullDocument });
await ctx.db.delete(id);
Common Pitfalls
Pitfall 1: Calling fetch() in Mutations
❌ WRONG:
export const createOrder = mutation({
args: { productId: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// ❌ Mutations must be deterministic - no external calls!
const price = await fetch(
"https://api.stripe.com/prices/" + args.productId
);
await ctx.db.insert("orders", { productId: args.productId, price });
return null;
},
});
✅ CORRECT:
// Mutation creates order, schedules action for external call
export const createOrder = mutation({
args: { productId: v.string() },
returns: v.id("orders"),
handler: async (ctx, args) => {
const orderId = await ctx.db.insert("orders", {
productId: args.productId,
status: "pending",
});
await ctx.scheduler.runAfter(0, internal.orders.fetchPrice, { orderId });
return orderId;
},
});
// Action handles external API call
export const fetchPrice = internalAction({
args: { orderId: v.id("orders") },
returns: v.null(),
handler: async (ctx, args) => {
const order = await ctx.runQuery(internal.orders.getById, {
orderId: args.orderId,
});
const price = await fetch(
"https://api.stripe.com/prices/" + order.productId
);
await ctx.runMutation(internal.orders.updatePrice, {
orderId: args.orderId,
price: await price.json(),
});
return null;
},
});
Pitfall 2: Accessing ctx.db in Actions
❌ WRONG:
export const processData = action({
args: { id: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// ❌ Actions don't have ctx.db!
const data = await ctx.db.get(args.id);
return null;
},
});
✅ CORRECT:
export const processData = action({
args: { id: v.id("data") },
returns: v.null(),
handler: async (ctx, args) => {
// ✅ Use ctx.runQuery to read data
const data = await ctx.runQuery(internal.data.getById, { id: args.id });
// Do external work...
const result = await fetch("...");
// ✅ Use ctx.runMutation to write data
await ctx.runMutation(internal.data.update, {
id: args.id,
result: await result.json(),
});
return null;
},
});
Pitfall 3: Missing returns Validator
❌ WRONG:
export const foo = mutation({
args: {},
handler: async (ctx) => {
// implicitly returns undefined
},
});
✅ CORRECT:
export const foo = mutation({
args: {},
returns: v.null(),
handler: async (ctx) => {
return null;
},
});
Decision Guide
- Does this function read from DB? → Use
queryorinternalQuery - Does this function write to DB? → Use
mutationorinternalMutation - Does this function call external APIs? → Use
actionorinternalAction - Is this called by clients? → Use public variants (
query,mutation,action) - Is this internal only? → Use
internal*variants - Is this a webhook endpoint? → Use
httpActioninconvex/http.ts
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
vercel-ai-sdk
Guide for Vercel AI SDK v6 implementation patterns including generateText, streamText, ToolLoopAgent, structured output with Output helpers, useChat hook, tool calling, embeddings, middleware, and MCP integration. Use when implementing AI chat interfaces, streaming responses, agentic applications, tool/function calling, text embeddings, workflow patterns, or working with convertToModelMessages and toUIMessageStreamResponse. Activates for AI SDK integration, useChat hook usage, message streaming, agent development, or tool calling tasks.
convex-anti-patterns
Critical rules and common mistakes to avoid in Convex development. Use when reviewing Convex code, debugging issues, or learning what NOT to do. Activates for code review, debugging OCC errors, fixing type errors, or understanding why code fails in Convex.
convex-helpers-patterns
Guide for convex-helpers library patterns including Triggers, Row-Level Security (RLS), Relationship helpers, Custom Functions, Rate Limiting, and Workpool. Use when implementing automatic side effects, access control, relationship traversal, auth wrappers, or concurrency management. Activates for triggers setup, RLS implementation, custom function wrappers, or convex-helpers integration tasks.
convex-schema-validators
Guide for Convex schema design, validators, and TypeScript types. Use when defining database schemas, creating validators for function arguments/returns, working with document types, or ensuring type safety. Activates for schema.ts creation, validator usage, Id/Doc type handling, or TypeScript integration tasks.
typescript-strict-mode
Guide for strict TypeScript practices including avoiding any, using proper type annotations, and leveraging TypeScript's type system effectively. Use when working with TypeScript codebases that enforce strict type checking, when you need guidance on type safety patterns, or when encountering type errors. Activates for TypeScript type errors, strict mode violations, or general TypeScript best practices.
convex-actions-scheduling
Guide for Convex actions, scheduling, cron jobs, and orchestration patterns. Use when implementing external API calls, background jobs, scheduled tasks, cron jobs, or multi-step workflows. Activates for action implementation, ctx.scheduler usage, crons.ts creation, or long-running workflow tasks.
Didn't find tool you were looking for?