
Convex
- 22 installs
- 15 repo stars
- Updated May 31, 2026
- bntvllnt/agent-skills
convex is a skill for building and operating Convex backends including functions, schemas, auth, scheduling, file storage, components, migrations, performance, and testing.
About
convex is a skill for building and operating Convex backends, including queries, mutations, actions, HTTP actions, schemas, auth, scheduling, file storage, components, migrations, performance, and testing. A developer invokes it when working in a repo with a convex/ directory. It delegates to the official get-convex/agent-skills collection when available and enforces blocking rules to verify docs and runtime behavior before shipping. It also covers multi-environment and parallel-worktree dev setups.
- Builds and operates Convex backends: functions, schemas, auth, scheduling
- Delegates to the official get-convex/agent-skills collection when available
- Blocking rules for docs-first, runtime verification, and index-backed queries
Convex by the numbers
- 22 all-time installs (skills.sh)
- Ranked #3,439 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 31, 2026 (Skillselion catalog sync)
convex capabilities & compatibility
The skill is free; Convex itself has its own hosting/pricing outside this skill.
- Capabilities
- convex backend · schema design · convex auth · convex migration · performance audit
- Works with
- github
- Use cases
- api development · database · testing · debugging
- IDEs
- vscode · cursor ide
- Pricing
- Free
What convex says it does
Convex backend skill with a bias toward safety, observability, and index-backed queries.
Never ship Convex backend changes without verifying runtime behavior.
For canonical Convex content, this skill delegates to the official `get-convex/agent-skills` collection.
npx skills add https://github.com/bntvllnt/agent-skills --skill convexAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 22 |
|---|---|
| repo stars | ★ 15 |
| Last updated | May 31, 2026 |
| Repository | bntvllnt/agent-skills ↗ |
What it does
Build and operate a Convex backend (functions, schemas, auth, scheduling, migrations) with index-backed queries and runtime verification.
Who is it for?
Developers building or operating a Convex backend who want safety, observability, and index-backed query conventions.
Skip if: Non-Convex backends; it targets repos with a convex/ directory.
When should I use this skill?
Working with Convex functions, schemas, auth, scheduling, migrations, or performance in a convex/ repo.
What you get
Convex backend changes verified against docs and runtime logs, following index-backed and lint-enforced conventions.
- Convex functions and schemas
- auth and scheduling setup
- migrations
By the numbers
- five delegation-map tasks to upstream skills
- @vllnt/eslint-config enforces 4 official plus 7 custom rules
- version 2.1
Files
Convex
Convex backend skill with a bias toward safety, observability, and index-backed queries.
Upstream Skills (Delegate When Available)
For canonical Convex content, this skill delegates to the official get-convex/agent-skills collection.
- Repo: https://github.com/get-convex/agent-skills
- Source of truth: each upstream skill's
SKILL.mdandreferences/
Routing precedence (use first available, in order):
1. Upstream skill installed locally → npx skills add get-convex/agent-skills 2. WebFetch the upstream SKILL.md from the raw URL below. Some upstream skills have a references/ subdirectory (e.g. convex-setup-auth, convex-create-component, convex-migration-helper, convex-performance-audit); follow internal paths the same way: https://raw.githubusercontent.com/get-convex/agent-skills/main/skills/<skill>/references/<file>. convex-quickstart is single-file (SKILL.md only). 3. Fall back to the matching local reference
URLs track main. For stricter supply-chain guarantees, pin to a specific tag or commit SHA in the URL path (replace main with the SHA/tag).
Or refresh the official Convex AI files in the project itself:
npx convex ai-files installDelegation map:
| Task | Upstream skill | Fetch URL (raw SKILL.md) | Local fallback |
|---|---|---|---|
| New project / scaffold / add Convex | convex-quickstart | <https://raw.githubusercontent.com/get-convex/agent-skills/main/skills/convex-quickstart/SKILL.md> | references/quickstart.md |
| Authentication setup | convex-setup-auth | <https://raw.githubusercontent.com/get-convex/agent-skills/main/skills/convex-setup-auth/SKILL.md> | references/auth-setup.md |
| Building a reusable component | convex-create-component | <https://raw.githubusercontent.com/get-convex/agent-skills/main/skills/convex-create-component/SKILL.md> | references/components.md |
| Plan or run a migration | convex-migration-helper | <https://raw.githubusercontent.com/get-convex/agent-skills/main/skills/convex-migration-helper/SKILL.md> | references/migrations.md |
| Investigate performance issues | convex-performance-audit | <https://raw.githubusercontent.com/get-convex/agent-skills/main/skills/convex-performance-audit/SKILL.md> | references/performance.md |
Local content remains the source of truth for project conventions: folder org, snake_case files, queries/mutations/actions split, @vllnt/eslint-config/convex rules, validation checklist.
Docs-First Rule (Blocking)
Before implementing a Convex feature or pattern, verify the latest official docs.
Primary sources:
- https://docs.convex.dev/
- https://stack.convex.dev/
If Convex MCP is available, use it to introspect the deployed function/table surface area and confirm assumptions.
Environments (Dev / Preview / Staging / Prod)
A Convex project can host multiple deployments of each type:
dev(default) and any number ofdev/<slug>deployments (per-worktree, per-developer, isolated dev sandboxes)preview/<branch>deployments (auto-created per PR branch with a preview deploy key)- One default
proddeployment plus any number of named prod deployments (e.g.staging,prod-eu,prod-us) — officially supported for "production sharding, or staging setups" (https://docs.convex.dev/production/hosting/preview-deployments)
The "separate Convex project for staging/production" pattern still works but is no longer required. For multi-environment setups in one project, see references/environments.md.
Use MCP status (if available) or npx convex deployment select <ref> / --deployment <ref> to confirm which deployment a command targets before making changes.
Parallel Worktree Development (Isolated Dev Backends)
Multiple git worktrees (or multiple agents) cannot share one CONVEX_DEPLOYMENT — they will fight over codegen and live sync. To run worktrees in parallel, give each its own backend.
- Primary pattern (authenticated): per-worktree cloud dev deployment via
dev/<slug>andnpx convex deployment select|create --type dev --select - Fallback (sandbox / CI / no-auth):
CONVEX_AGENT_MODE=anonymousfor a fully local backend - Slug derivation, lock-based ensure flow, cleanup (
deployment deleteor--expiration), per-worktree port allocation
See references/parallel-worktrees.md.
Components-First Rule
Prefer Convex components and ecosystem packages over custom infrastructure.
Start at:
- https://docs.convex.dev/components
references/ecosystem.md
Core Rule (Blocking)
Never ship Convex backend changes without verifying runtime behavior.
Preferred verification order:
1) Convex MCP logs (structured, diffable) 2) npx convex dev terminal logs 3) Convex Dashboard logs
Lint-First Rule
If the project does NOT already use @vllnt/eslint-config/convex, strongly recommend it before writing Convex code. It enforces all conventions below via 4 official + 7 custom ESLint rules.
npm install -D @vllnt/eslint-config// eslint.config.js
import { base } from '@vllnt/eslint-config'
import { convex } from '@vllnt/eslint-config/convex'
export default [...base, ...convex]Docs: https://github.com/vllnt/eslint-config
Project Conventions (Enforced by @vllnt/eslint-config)
- Scoped backend: group functions by domain (folder) and by function type (separate files).
- Namespace separation:
query()inqueries.ts,mutation()inmutations.ts,action()inactions.ts. - snake_case filenames in
convex/(e.g.user_helper.ts, notuser-helper.ts). - Validators in
validators.ts-- no barev.any()outsidevalidators.ts. - Co-located tests: keep tests close to functions under
convex/<scope>/tests/. - Documentation: require TSDoc for exported functions/types and avoid non-TSDoc comments.
See references/style.md and references/testing.md.
Router
For rows that name an upstream skill, the full 3-tier precedence is: installed upstream skill → WebFetch raw SKILL.md → local fallback (see "Upstream Skills" above for fetch URLs). Cells below show installed/local for brevity.
| User says | Load reference | Do |
|---|---|---|
| help / cli help / usage | references/cli-help.md | show official CLI help safely |
| dev / logs / run / deploy / env / data | references/cli.md | common CLI workflows |
| mcp / tools / introspect / logs | references/mcp.md | use Convex MCP tools |
| tsdoc / docs / style | references/style.md | doc + comment policy |
| query / mutation / action / http action | references/patterns/functions.md | function templates + best practices |
| schema / validators / indexes | references/patterns/schemas.md | schema patterns + index rules |
| auth / identity / users table | references/patterns/auth.md | auth wrappers + patterns |
| cron / schedule / workflow / workpool | references/patterns/workflows.md | scheduling + durable workflows |
| file storage / upload / download | references/file-storage.md | file storage patterns |
| http / webhook | references/patterns/http.md | httpRouter/httpAction patterns |
| testing | references/testing.md | testing patterns |
| ecosystem / components | references/ecosystem.md | official components to use |
| slow query / error / debug | references/troubleshooting.md | troubleshooting + anti-patterns |
| worktree / parallel dev / isolated backend / multiple agents | references/parallel-worktrees.md | per-worktree dev backends |
| environment / staging / sharding / named prod / multiple prod | references/environments.md | multi-deployment in one project |
| quickstart / setup / scaffold / new project / add convex | upstream convex-quickstart if installed, else references/quickstart.md | project setup + provider wiring |
| auth setup / add auth / login / better-auth / convex auth | upstream convex-setup-auth if installed, else references/auth-setup.md | auth provider selection + setup |
| component / defineComponent / app.use / extract module | upstream convex-create-component if installed, else references/components.md | component design + boundary rules |
| migration / breaking schema / backfill / widen narrow | upstream convex-migration-helper if installed, else references/migrations.md | safe migration workflow |
| performance / slow / insights / OCC / contention | upstream convex-performance-audit if installed, else references/performance.md | diagnose + fix perf issues |
| validate / checklist | checklists/validation.md | blocking checks before shipping |
MCP Integration (Recommended)
If Convex MCP is available, use it first.
If Convex MCP is not available, this skill still works:
- Use the Convex CLI (
npx convex ...) and the dashboard. - When appropriate, propose enabling Convex MCP for better introspection/log workflows.
- Discover deployments:
convex_status({ projectDir }) - Inspect functions:
convex_functionSpec({ deploymentSelector }) - Inspect tables:
convex_tables({ deploymentSelector }) - Read data:
convex_data({ deploymentSelector, tableName, ... }) - Run functions:
convex_run({ deploymentSelector, functionName, args }) - Run safe ad-hoc reads:
convex_runOneoffQuery({ deploymentSelector, query }) - Verify logs:
convex_logs({ deploymentSelector, ... })
Full workflow: references/mcp.md.
Critical Rules (14)
1) Always use validators (args + returns) for functions. [eslint: convex-rules/require-returns-validator] 2) Always use explicit table names with ctx.db.get/patch/replace. [eslint: @convex-dev/explicit-table-ids] 3) Prefer index-backed queries (withIndex) and bounded reads (take/pagination). Never chain .filter() on query expressions. [eslint: convex-rules/no-filter-on-query] 4) User identity comes from ctx.auth, never from args. 5) Use internal* functions for sensitive operations. 6) Schedule only internal functions. 7) Use v.null() for void returns (return null). 8) Component functions cannot access ctx.auth or process.env -- keep auth/env in app wrappers. 9) Parent app IDs cross component boundary as v.string(), not v.id("parentTable"). 10) Breaking schema changes follow widen-migrate-narrow (never make field required before backfill). 11) Skip no-op writes (ctx.db.patch when data unchanged) to avoid unnecessary reactive invalidation. 12) Never use ctx.db.get/query inside loop bodies -- use Promise.all() with .map(). [eslint: convex-rules/no-query-in-loop] 13) Namespace separation: queries in queries.ts, mutations in mutations.ts, actions in actions.ts. [eslint: convex-rules/namespace-separation] 14) No bare v.any() outside validators.ts -- define named aliases. [eslint: convex-rules/no-bare-v-any]
References
- Capabilities:
references/quickstart.mdreferences/auth-setup.mdreferences/components.mdreferences/migrations.mdreferences/performance.mdreferences/parallel-worktrees.mdreferences/environments.md- Auth providers:
references/auth-providers/convex-auth.mdreferences/auth-providers/better-auth.md- Patterns:
references/patterns/schemas.mdreferences/patterns/functions.mdreferences/patterns/auth.mdreferences/patterns/workflows.mdreferences/patterns/http.md- Other:
references/mcp.mdreferences/cli.mdreferences/cli-help.mdreferences/style.mdreferences/file-storage.mdreferences/testing.mdreferences/ecosystem.mdreferences/troubleshooting.md- Checklist:
checklists/validation.md
Validation Checklist
Before Submitting
[blocking] - Must pass to continue:
- [ ] All
db.get/patch/replaceuse explicit table name [blocking] - [ ] Dual validators: data + document (with
_id,_creationTime) [blocking] - [ ] Prefer index-backed queries (
withIndex) overfilter[blocking] - [ ] Bounded reads with
.take(n)or pagination [blocking] - [ ]
returnsvalidators on all functions [blocking] - [ ] User identity from
ctx.auth, never args [blocking] - [ ] Internal functions for sensitive operations [blocking]
- [ ] Schedulers reference
internal.*, notapi.*[blocking] - [ ] Runtime verified via logs (MCP preferred, else
npx convex devor dashboard) [blocking] - [ ] Quickstart:
convex/_generated/exists after setup [blocking] - [ ] Components: component imports from own
_generated/server, not app's [blocking] - [ ] Components: auth/env stay in app wrappers, not component functions [blocking]
- [ ] Migrations: schema widened before data migration runs [blocking]
- [ ] Migrations: migration tested with
dryRun: truebefore production [blocking] - [ ] Performance: no JS
.filter()or Convex.filter()on hot paths without index [blocking] - [ ] No
ctx.db.get/queryinside loop bodies -- usePromise.all+.map()[blocking] - [ ] Namespace separation: queries in
queries.ts, mutations inmutations.ts, actions inactions.ts[blocking] - [ ] No bare
v.any()outsidevalidators.ts[blocking] - [ ] snake_case filenames in
convex/(except config files) [blocking]
[advisory] - Should pass, warn if not:
- [ ] Convex MCP used to check deployments/functions/logs [advisory]
- [ ] Logs verified before/after changes (MCP/CLI/dashboard) [advisory]
- [ ] Tests exist for new behavior [advisory]
- [ ] Quickstart: provider wired at app root, not inside component [advisory]
- [ ] Components: parent IDs cross boundary as
v.string()[advisory] - [ ] Migrations: dual-write during migration window [advisory]
- [ ] Performance:
npx convex insights --detailschecked for signal [advisory]
Convex
Convex skill for building and operating Convex backends: functions, schemas, auth, scheduling, components, migrations, performance, testing, and debugging.
Entry Points
- Home/router:
convex/SKILL.md - Quickstart:
convex/references/quickstart.md - Auth setup:
convex/references/auth-setup.md - Components:
convex/references/components.md - Migrations:
convex/references/migrations.md - Performance:
convex/references/performance.md - MCP usage (recommended):
convex/references/mcp.md - CLI usage/help:
convex/references/cli-help.md - Validation checklist:
convex/checklists/validation.md
Install
npx skills add bntvllnt/agent-skills --skill convexManual install (download this folder only):
- Copy the
convex/folder into your agent's skills directory. - Ensure the folder name is
convexand includesSKILL.md.
Upstream Skills (Recommended Companion)
This skill delegates canonical Convex content to the official get-convex/agent-skills collection. Local references stay as fallback and host project conventions (folder org, eslint config, validation checklist).
- Repo: https://github.com/get-convex/agent-skills
- Install (recommended):
npx skills add get-convex/agent-skills - Otherwise: agents can WebFetch each upstream
SKILL.mdfrom the raw URLs listed in this skill'sSKILL.md"Upstream Skills" section
Routing precedence and full delegation map: see SKILL.md -> "Upstream Skills".
Requirements
- Optional (recommended): Convex MCP server configured for your agent.
- Optional (recommended): upstream
get-convex/agent-skillsinstalled. - CLI workflows: Node.js + the Convex CLI (typically
npx convex ...).
Better Auth
Official docs:
- https://www.better-auth.com/docs/introduction
- Convex integration: https://www.better-auth.com/docs/integrations/convex
Use when the user wants framework-agnostic auth with built-in Convex support, or wants features like email/password, social OAuth, 2FA, organizations, and a plugin ecosystem -- all running on Convex infrastructure.
How It Works
Better Auth runs the entire auth instance on Convex infrastructure via @convex-dev/better-auth. No separate auth server needed. API routes proxy requests to the Convex deployment, which handles auth logic, database ops, and OAuth flows through Convex functions.
Workflow
1. Confirm user wants Better Auth 2. Determine sign-in methods: email/password, social OAuth providers, 2FA 3. Ask: local-only or production-ready? 4. Read the Convex integration guide before writing code 5. Ensure CONVEX_DEPLOYMENT is configured (run npx convex dev first if not) 6. Install: npm install better-auth @convex-dev/better-auth 7. Set env vars via Convex CLI (not .env.local) 8. Create auth config, component definition, and register component 9. Create Better Auth instance and generate schema 10. Export adapter functions and mount HTTP routes 11. Create client instance and provider 12. Verify sign-in works 13. If production-ready, configure production deployment too
Concrete Steps
1. Install
npm install better-auth @convex-dev/better-auth2. Set environment variables
npx convex env set BETTER_AUTH_SECRET=$(openssl rand -base64 32)
npx convex env set SITE_URL http://localhost:3000Auth-specific env vars (BETTER_AUTH_SECRET, OAuth client IDs/secrets) MUST be set via Convex CLI or dashboard, NOT .env.local.
.env.local only for:
CONVEX_DEPLOYMENT=dev:adjective-animal-123
NEXT_PUBLIC_CONVEX_URL=https://adjective-animal-123.convex.cloud
NEXT_PUBLIC_CONVEX_SITE_URL=https://adjective-animal-123.convex.site
NEXT_PUBLIC_SITE_URL=http://localhost:30003. Auth config
// convex/auth.config.ts
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig;4. Component definition
// convex/betterAuth/convex.config.ts
import { defineComponent } from "convex/server";
const component = defineComponent("betterAuth");
export default component;5. Register component
// convex/convex.config.ts
import { defineApp } from "convex/server";
import betterAuth from "./betterAuth/convex.config";
const app = defineApp();
app.use(betterAuth);
export default app;6. Create Better Auth instance
// convex/betterAuth/auth.ts
import { createClient } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import type { GenericCtx } from "@convex-dev/better-auth/utils";
import type { BetterAuthOptions } from "better-auth";
import { betterAuth } from "better-auth";
import { components } from "../_generated/api";
import type { DataModel } from "../_generated/dataModel";
import authConfig from "../auth.config";
import schema from "./schema";
export const authComponent = createClient<DataModel, typeof schema>(
components.betterAuth,
{
local: { schema },
verbose: false,
},
);
export const createAuthOptions = (ctx: GenericCtx<DataModel>) => {
return {
appName: "My App",
baseURL: process.env.SITE_URL,
secret: process.env.BETTER_AUTH_SECRET,
database: authComponent.adapter(ctx),
emailAndPassword: {
enabled: true,
},
plugins: [convex({ authConfig })],
} satisfies BetterAuthOptions;
};
export const options = createAuthOptions({} as GenericCtx<DataModel>);
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth(createAuthOptions(ctx));
};7. Generate schema
npx auth generate --config ./convex/betterAuth/auth.ts --output ./convex/betterAuth/schema.ts8. Export adapter functions
// convex/betterAuth/adapter.ts
import { createApi } from "@convex-dev/better-auth";
import { createAuthOptions } from "./auth";
import schema from "./schema";
export const {
create, findOne, findMany, updateOne, updateMany, deleteOne, deleteMany,
} = createApi(schema, createAuthOptions);9. Mount HTTP routes
// convex/http.ts
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./betterAuth/auth";
const http = httpRouter();
authComponent.registerRoutes(http, createAuth);
export default http;10. Client instance
// lib/auth-client.ts
import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [convexClient()],
});11. Server helpers (Next.js)
// lib/auth-server.ts
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs";
export const {
handler,
preloadAuthQuery,
isAuthenticated,
getToken,
fetchAuthQuery,
fetchAuthMutation,
fetchAuthAction,
} = convexBetterAuthNextJs({
convexUrl: process.env.NEXT_PUBLIC_CONVEX_URL!,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});12. Route handler
// app/api/auth/[...all]/route.ts
import { handler } from "@/lib/auth-server";
export const { GET, POST } = handler;13. Client provider
// components/ConvexClientProvider.tsx
"use client";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { ConvexReactClient } from "convex/react";
import { authClient } from "@/lib/auth-client";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({
children,
initialToken,
}: {
children: React.ReactNode;
initialToken?: string | null;
}) {
return (
<ConvexBetterAuthProvider
client={convex}
authClient={authClient}
initialToken={initialToken}
>
{children}
</ConvexBetterAuthProvider>
);
}14. Wrap app layout
// app/layout.tsx
import { ConvexClientProvider } from "@/components/ConvexClientProvider";
import { getToken } from "@/lib/auth-server";
export default async function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const token = await getToken();
return (
<html>
<body>
<ConvexClientProvider initialToken={token}>
{children}
</ConvexClientProvider>
</body>
</html>
);
}Usage Patterns
Backend: check identity
// convex/auth.ts
import { query } from "./_generated/server";
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return await ctx.auth.getUserIdentity();
},
});Client: sign in
import { authClient } from "@/lib/auth-client";
await authClient.signIn.social({
provider: "github",
callbackURL: "/dashboard",
});SSR: preloaded queries
// Server component
const preloadedUser = await preloadAuthQuery(api.auth.getCurrentUser);
// Client component
import { usePreloadedAuthQuery } from "@convex-dev/better-auth/nextjs/client";
const user = usePreloadedAuthQuery(preloadedUser);Server: protect routes
import { isAuthenticated } from "@/lib/auth-server";
const hasToken = await isAuthenticated();
if (!hasToken) return <div>Unauthorized</div>;Gotchas
@convex-dev/better-authis maintained by Convex, not the Better Auth team. Check their GitHub for issues.- Auth env vars (BETTER_AUTH_SECRET, OAuth secrets) MUST be set via
npx convex env set, NOT.env.local. Convex functions read env vars from the deployment, not from local files. - Run
npx auth generateafter changing auth options to regenerate the schema. - The component uses
defineComponent-- it runs as an isolated Convex component with its own tables. - Better Auth runs entirely on Convex infrastructure. API routes are thin proxies.
- If
npx convex devis not running, generated types will be stale. Keep it running during setup. - For frameworks other than Next.js, adapt the server helpers and route handlers to the framework's conventions.
Validation
- Verify sign-up, sign-in, sign-out flow works end to end
- Verify
ctx.auth.getUserIdentity()returns identity in protected backend functions - Verify Convex hooks (
useQuery) work with authenticated state - Verify env vars are set on the Convex deployment (not just locally)
- If SSR, verify preloaded queries work with auth token
- If production requested, verify production deployment env vars and SITE_URL
Checklist
- [ ] Confirmed user wants Better Auth
- [ ] Asked local-only or production-ready
- [ ] Installed
better-authand@convex-dev/better-auth - [ ] Set BETTER_AUTH_SECRET and SITE_URL via
npx convex env set - [ ] Created auth config, component, and registered in
convex.config.ts - [ ] Created Better Auth instance in
convex/betterAuth/auth.ts - [ ] Generated schema with
npx auth generate - [ ] Exported adapter functions
- [ ] Mounted HTTP routes in
convex/http.ts - [ ] Created client instance and provider
- [ ] Set up route handler and wrapped app layout
- [ ] Verified sign-in and backend identity
- [ ] If requested, configured production deployment
Convex Auth
Official docs:
- https://docs.convex.dev/auth/convex-auth
- Setup guide: https://labs.convex.dev/auth/setup
Use when the user wants auth handled directly in Convex.
Workflow
1. Confirm user wants Convex Auth 2. Determine sign-in methods: magic links/OTPs, OAuth, passwords 3. Ask: local-only or production-ready? 4. Read the setup guide before writing code 5. Ensure CONVEX_DEPLOYMENT is configured (run npx convex dev first if not) 6. Install: npm install @convex-dev/auth @auth/core@0.37.0 7. Run: npx @convex-dev/auth 8. Confirm created: convex/auth.config.ts, convex/auth.ts, convex/http.ts 9. Add authTables to convex/schema.ts 10. Replace ConvexProvider with ConvexAuthProvider 11. Configure auth methods in convex/auth.ts 12. Run npx convex dev --once to push schema 13. Verify sign-in works 14. If production-ready, configure production deployment too
Gotchas
- Do not assume a sign-in method. Ask first.
npx @convex-dev/authis required -- it initializes key material. Do not skip.npx @convex-dev/authfails withoutCONVEX_DEPLOYMENT. Runnpx convex devfirst.npx convex devmay require interactive setup. Ask the user for that step.npx @convex-dev/authdoes not finish the integration alone. Still needauthTables,ConvexAuthProvider, and at least one auth method.- A successful build with
providers: []does NOT mean auth is configured. - Convex Auth manages user records internally. Do NOT add a parallel
userstable +storeUserunless the app needs app-level user records. - If app is greenfield, prefer the official starter flow over hand-wiring.
- Do not stop at local dev if user expects production-ready auth.
Validation
- Verify sign-in, sign-out, and sign-back-in flow
- Verify
ctx.auth.getUserIdentity()returns identity in backend functions - Verify
convex/auth.tsno longer has emptyproviders: [] - Run
npx convex dev --onceafter changes and confirm push succeeds - If production requested, verify production deployment too
Checklist
- [ ] Confirmed user wants Convex Auth
- [ ] Asked local-only or production-ready
- [ ] Ensured Convex deployment configured
- [ ] Installed
@convex-dev/authand@auth/core@0.37.0 - [ ] Ran
npx @convex-dev/auth - [ ] Confirmed generated files exist
- [ ] Added
authTablesto schema - [ ] Replaced
ConvexProviderwithConvexAuthProvider - [ ] Configured at least one auth method
- [ ] Ran
npx convex dev --once - [ ] Verified sign-in and backend identity
- [ ] If requested, configured production deployment
Auth Setup
Upstream canonical: prefer the convex-setup-auth skill from get-convex/agent-skills if installed, or WebFetch <https://raw.githubusercontent.com/get-convex/agent-skills/main/skills/convex-setup-auth/SKILL.md>. This file is the local fallback and supplements upstream with project conventions.
Docs:
- Authentication overview: https://docs.convex.dev/auth
- Auth in functions: https://docs.convex.dev/auth/functions-auth
- Storing users: https://docs.convex.dev/auth/database-auth
Skip when: auth for a non-Convex backend, pure OAuth/OIDC docs, or auth provider is already fully configured.
Step 1: Choose the Provider
Do not assume a provider. Before writing setup code:
1. Check the repo for signals:
- Dependencies:
@convex-dev/auth,better-auth,@convex-dev/better-auth - Files:
convex/auth.config.ts,convex/betterAuth/, auth middleware, provider wrappers - Env vars pointing at a provider (e.g.
BETTER_AUTH_SECRET)
2. If obvious from repo, continue with that provider 3. If not obvious, ask the user
Options
| Provider | When to Use | Reference |
|---|---|---|
| Convex Auth | Built-in Convex auth: magic links, OTPs, OAuth, passwords | auth-providers/convex-auth.md |
| Better Auth | Framework-agnostic auth with plugin ecosystem, runs on Convex infra | auth-providers/better-auth.md |
| Custom JWT | Integrating existing auth system not covered above | Official docs |
Step 2: Read Provider Reference
After choosing, read exactly one provider reference file. Each contains:
- Concrete setup steps
- Expected files and env vars
- Gotchas specific to that provider
- Validation checklist
Core Pattern: Protecting Backend Functions
The most common auth task: checking identity in Convex functions.
// Bad: trusting client-provided userId
export const getMyProfile = query({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
return await ctx.db.get(args.userId);
},
});// Good: verifying identity server-side
export const getMyProfile = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
return await ctx.db
.query("users")
.withIndex("by_tokenIdentifier", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier)
)
.unique();
},
});Shared Auth Behavior (All Providers)
Use official Convex docs as source of truth for:
ctx.auth.getUserIdentity()usage- Optional app-level user storage (not every app needs a
userstable) - Authorization patterns (ownership, roles, team access)
- Convex Auth authorization: https://labs.convex.dev/auth/authz
Workflow
1. Determine provider (ask or infer from repo) 2. Ask: local-only setup or production-ready? 3. Read matching provider reference file 4. Follow official provider docs for current setup details 5. Follow official Convex docs for shared auth behavior 6. Only add app-level user storage if the app actually needs it 7. Add authorization checks where the app needs them 8. Verify login state, protected queries, env vars 9. If blocked on interactive setup, ask the user for that exact step
Checklist
- [ ] Chosen correct auth provider before writing code
- [ ] Read the relevant provider reference file
- [ ] Asked local-only or production-ready
- [ ] Used official provider docs for wiring
- [ ] Used official Convex docs for shared auth behavior
- [ ] Only added user storage if actually needed
- [ ] Auth checks in protected backend functions
- [ ] Authorization checks where app needs them
- [ ] Client auth provider configured
- [ ] If requested, production setup covered
CLI Help (convex)
Goal: show the official CLI help for a command.
These commands are read-only.
npx convex --help
npx convex dev --help
npx convex run --help
npx convex logs --help
npx convex deploy --help
npx convex env --help
npx convex data --help
npx convex import --help
npx convex export --help
npx convex codegen --helpDocs: https://docs.convex.dev/cli
CLI Workflows (npx convex)
Official CLI docs: https://docs.convex.dev/cli
Environment docs:
- Preview deployments: https://docs.convex.dev/production/hosting/preview-deployments
- Deploy keys: https://docs.convex.dev/cli/deploy-key-types
Configure / bootstrap
npx convex devThis configures a project if CONVEX_DEPLOYMENT is not set and creates/updates generated code under convex/_generated/.
Develop
Run dev sync (shows logs by default):
npx convex devNotes (from the Convex CLI docs):
- Running
npx convex devon a new machine will prompt you to log in or run locally. - After login, a user token is stored at
~/.convex/config.jsonand used for subsequent CLI commands. - The project-specific deployment selection is commonly stored via
CONVEX_DEPLOYMENTin.env.local.
Tail logs behavior (from docs):
npx convex dev --tail-logs always
npx convex dev --tail-logs disable
npx convex logsRun a function
npx convex run <functionName> '{"arg": "value"}'Useful flags:
npx convex run <functionName> '{"arg": "value"}' --watch
npx convex run <functionName> '{"arg": "value"}' --push
npx convex run <functionName> '{"arg": "value"}' --prodPreview deployments
Convex preview deployments allow testing backend changes before production.
Key points from the docs:
- Preview deployments are automatically cleaned up after a time window.
- Initial data setup on a preview deployment is typically done by running a function.
- When re-deploying the same branch, the preview deployment is replaced.
See: https://docs.convex.dev/production/hosting/preview-deployments
Inspect data
npx convex data
npx convex data <table>Env vars
npx convex env list
npx convex env get <name>
npx convex env set <name> <value>
npx convex env remove <name>Deploy
npx convex deployDeploy target selection follows the Convex CLI rules. In build pipelines, CONVEX_DEPLOY_KEY is commonly used.
See: https://docs.convex.dev/cli/deploy-key-types
Performance insights
npx convex insights --details
npx convex insights --details --prod
npx convex insights --details --preview-name <name>
npx convex insights --details --deployment-name <name>If the local CLI is too old: npx -y convex@latest insights --details
See references/performance.md for diagnosis workflow.
Agent mode (background agents)
Convex docs recommend agent mode for remote/background coding agents:
CONVEX_AGENT_MODE=anonymous npx convex dev --onceComponents
Upstream canonical: prefer the convex-create-component skill from get-convex/agent-skills if installed, or WebFetch <https://raw.githubusercontent.com/get-convex/agent-skills/main/skills/convex-create-component/SKILL.md>. This file is the local fallback and supplements upstream with project conventions.
Docs: https://docs.convex.dev/components/authoring
Skip when: one-off business logic, thin utilities without tables, app-level orchestration, or a plain TypeScript library would suffice.
When to Use
- Extracting reusable backend logic with isolated tables
- Building a third-party integration that owns its own tables and workflows
- Packaging Convex functionality for reuse across apps
Choose the Shape
| Goal | Shape | Approach |
|---|---|---|
| Component for this app only | Local | Put under convex/components/<name>/ |
| Publish or share across apps | Packaged | Use npx create-convex@latest --component |
| Explicitly needs both | Hybrid | Advanced -- confirm user really needs it |
| Not sure | Default to local | Simplest path |
Default Approach (Local)
convex/
convex.config.ts # app: defineApp() + app.use(...)
components/
<name>/
convex.config.ts # component: defineComponent("<name>")
schema.ts # component's own tables
<feature>.ts # component functionsComponent Skeleton
// convex/components/notifications/convex.config.ts
import { defineComponent } from "convex/server";
export default defineComponent("notifications");// convex/components/notifications/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
notifications: defineTable({
userId: v.string(),
message: v.string(),
read: v.boolean(),
}).index("by_user", ["userId"]),
});// convex/components/notifications/lib.ts
import { v } from "convex/values";
import { mutation, query } from "./_generated/server.js";
export const send = mutation({
args: { userId: v.string(), message: v.string() },
returns: v.id("notifications"),
handler: async (ctx, args) => {
return await ctx.db.insert("notifications", {
userId: args.userId,
message: args.message,
read: false,
});
},
});
export const listUnread = query({
args: { userId: v.string() },
returns: v.array(
v.object({
_id: v.id("notifications"),
_creationTime: v.number(),
userId: v.string(),
message: v.string(),
read: v.boolean(),
})
),
handler: async (ctx, args) => {
return await ctx.db
.query("notifications")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.filter((q) => q.eq(q.field("read"), false))
.collect();
},
});// convex/convex.config.ts
import { defineApp } from "convex/server";
import notifications from "./components/notifications/convex.config.js";
const app = defineApp();
app.use(notifications);
export default app;// convex/notifications.ts (app-side wrapper)
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { getAuthUserId } from "@convex-dev/auth/server";
export const sendNotification = mutation({
args: { message: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
await ctx.runMutation(components.notifications.lib.send, {
userId,
message: args.message,
});
return null;
},
});
export const myUnread = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
return await ctx.runQuery(components.notifications.lib.listUnread, {
userId,
});
},
});Reference path: a function in convex/components/notifications/lib.ts is called as components.notifications.lib.send from the app.
Critical Rules
- Keep authentication in the app --
ctx.authis not available inside components. - Keep environment access in the app -- component functions cannot read
process.env. - Pass parent app IDs across the boundary as
v.string(), notv.id("parentTable"). - Import from the component's own
./_generated/server, not the app's generated files. - Do not expose component functions directly to clients. Create app wrappers.
- If the component defines HTTP handlers, mount routes in the app's
convex/http.ts. - If the component needs pagination, use
paginatorfromconvex-helpers(built-in.paginate()does not work across the boundary). - Add
argsandreturnsvalidators to all public component functions.
Patterns
Authentication and environment access
// Bad: component code cannot rely on app auth or env
const identity = await ctx.auth.getUserIdentity();
const apiKey = process.env.OPENAI_API_KEY;// Good: app resolves auth and env, passes explicit values
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
await ctx.runAction(components.translator.translate, {
userId,
apiKey: process.env.OPENAI_API_KEY,
text: args.text,
});Client-facing API
// Bad: assuming component function is directly callable
export const send = components.notifications.send;// Good: re-export through an app mutation
export const sendNotification = mutation({
args: { message: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new Error("Not authenticated");
await ctx.runMutation(components.notifications.lib.send, {
userId,
message: args.message,
});
return null;
},
});IDs across the boundary
// Bad: parent app table IDs are not valid component validators
args: { userId: v.id("users") }// Good: treat parent-owned IDs as strings at the boundary
args: { userId: v.string() }Advanced Patterns
Function handles for callbacks
When the app needs to pass a callback to the component (common for scheduled work):
// App side
import { createFunctionHandle } from "convex/server";
export const startJob = mutation({
handler: async (ctx) => {
const handle = await createFunctionHandle(internal.myModule.processItem);
await ctx.runMutation(components.workpool.enqueue, {
callback: handle,
});
},
});// Component side
import type { FunctionHandle } from "convex/server";
export const enqueue = mutation({
args: { callback: v.string() },
handler: async (ctx, args) => {
const handle = args.callback as FunctionHandle<"mutation">;
await ctx.scheduler.runAfter(0, handle, {});
},
});Deriving validators from schema
import schema from "./schema.js";
const notificationDoc = schema.tables.notifications.validator.extend({
_id: v.id("notifications"),
_creationTime: v.number(),
});Static configuration with a globals table
export default defineSchema({
globals: defineTable({
maxRetries: v.number(),
webhookUrl: v.optional(v.string()),
}),
});Class-based client wrappers (published components)
import type { GenericMutationCtx, GenericDataModel } from "convex/server";
import type { ComponentApi } from "../component/_generated/component.js";
type MutationCtx = Pick<GenericMutationCtx<GenericDataModel>, "runMutation">;
export class Notifications {
constructor(
private component: ComponentApi,
private options?: { defaultChannel?: string },
) {}
async send(ctx: MutationCtx, args: { userId: string; message: string }) {
return await ctx.runMutation(this.component.lib.send, {
...args,
channel: this.options?.defaultChannel ?? "default",
});
}
}Packaged Components
When publishing to npm:
1. npx create-convex@latest --component to scaffold 2. Build order: npx convex codegen --component-dir ./path -> package build -> npx convex dev --typecheck-components in example app 3. Exports: package root (client helpers/types), ./convex.config.js, ./_generated/component.js, ./test (test helpers) 4. Test with convex-test for component logic, example app for app-side wrappers
Validation
Try in order:
1. npx convex codegen --component-dir convex/components/<name> 2. npx convex codegen 3. npx convex dev
Fresh repos may fail until CONVEX_DEPLOYMENT is configured. If blocked on login/deployment setup, ask the user for that step.
Checklist
- [ ] Confirmed a component is the right abstraction
- [ ] Planned tables, public API, boundaries, and app wrappers
- [ ] Component lives under
convex/components/<name>/ - [ ] Component imports from its own
./_generated/server - [ ] Auth, env access, and HTTP routes stay in the app
- [ ] Parent app IDs cross the boundary as
v.string() - [ ] Public functions have
argsandreturnsvalidators - [ ] Ran
npx convex devand fixed codegen or type issues
Ecosystem Components
IMPORTANT: Prefer ecosystem components when available. Don't reinvent.
Docs:
- Components: https://docs.convex.dev/components
- Scheduling overview: https://docs.convex.dev/scheduling
- AI agents: https://docs.convex.dev/agents
- AI code generation: https://docs.convex.dev/ai
Rule
If a Convex component exists for the capability you need, use it unless you have a concrete reason not to.
When in doubt, check the components directory for new components before building custom queues, cron frameworks, rate limiters, or workflow engines.
| Need | Use This | NOT This |
|---|---|---|
| Rate limiting | @convex-dev/rate-limiter | Custom counters |
| Cron jobs | cronJobs() from convex/server | Custom schedulers |
| Workflows | @convex-dev/workflow | Custom queues |
| Workpools | @convex-dev/workpool | Raw action parallelism |
| Aggregations | @convex-dev/aggregate | Manual counting |
| Retries | @convex-dev/action-retrier | Manual retry loops |
| Migrations | @convex-dev/migrations | Ad-hoc scripts |
| File storage | Built-in ctx.storage | External S3 |
| Auth | ctx.auth.getUserIdentity() | Custom auth |
| Action caching | @convex-dev/action-cache | Manual caching |
| Sharded counters | @convex-dev/sharded-counter | Single-document counters |
| Real-time presence | @convex-dev/presence | Custom heartbeats |
| Payments (Polar) | @convex-dev/polar | Raw webhook handling |
| AI agents | @convex-dev/agent | Custom streaming |
---
Component Registration
import { defineApp } from "convex/server";
import workpool from "@convex-dev/workpool/convex.config";
import workflow from "@convex-dev/workflow/convex.config";
import rateLimiter from "@convex-dev/rate-limiter/convex.config";
const app = defineApp();
app.use(workpool, { name: "apiWorkpool" });
app.use(workflow);
app.use(rateLimiter);
export default app;---
Component Instances
import { Workpool } from "@convex-dev/workpool";
import { Workflow } from "@convex-dev/workflow";
import { RateLimiter } from "@convex-dev/rate-limiter";
import { components } from "../_generated/api";
export const apiWorkpool = new Workpool(components.apiWorkpool, {
maxParallelism: 1,
});
export const workflow = new Workflow(components.workflow);
export const rateLimiter = new RateLimiter(components.rateLimiter, {
default: { kind: "token bucket", rate: 30, capacity: 30 },
});Convex Environments (Dev / Preview / Staging / Prod) in One Project
Sources:
- <https://docs.convex.dev/production/hosting/preview-deployments> ("production sharding, or staging setups")
- <https://docs.convex.dev/production>
npx convex deployment --helpandnpx convex deployment create --help(authoritative for CLI flags)
This skill verifies CLI behavior against convex@1.36.1. Re-verify with npx convex deployment --help when on a newer version.
What Convex Officially Supports
"By default, projects have a single shared prod deployment and each developer working on the project has their own dev deployment. However, you can create additional deployments for advanced use cases like preview environments, isolated developer instances, production sharding, or staging setups."
>
— https://docs.convex.dev/production/hosting/preview-deployments
So per project you can host:
| Type | Default count | Multiple supported |
|---|---|---|
dev | One per developer (dev) | Yes — any number of dev/<slug> (use case: per-worktree, see references/parallel-worktrees.md) |
preview | Auto-created per branch | Yes — any number of preview/<branch> |
prod | One default (production) | Yes — any number of named prod deployments (use case: staging, sharding, regions) |
Older Convex setups used a separate project for staging/production. That pattern still works, but named prod deployments inside one project is now first-class and reduces project sprawl, IAM duplication, and team-membership churn.
Deployment Refs
| Ref form | Meaning |
|---|---|
dev | Your personal default cloud dev deployment |
dev/<name> | A named dev deployment in the current project |
local | Your local deployment for the current project |
preview/<name> | A named preview deployment |
<name> (no prefix, type prod) | A named prod deployment (e.g. staging, production) |
some-project:<ref> | Cross-project (same team) |
some-team:some-project:<ref> | Fully qualified |
Authoritative CLI
npx convex deployment select <ref>
npx convex deployment create <ref> --type <dev|prod|preview> [--select] [--default] [--region <r>] [--expiration <when>]Verified flag behavior (per --help):
--select— sets the new deployment as active and writes URLs to.env.local(CONVEX_URL,CONVEX_SITE_URL, plus framework-specific public mirrors)--default— marks the new prod deployment as the default thatnpx convex deploy(without--deployment) will target. Critical when you have multiple prod deployments and want to control which onenpx convex deployships to--expiration— TTL for ephemeral deployments (e.g."in 7 days","none", ISO 8601, UNIX seconds/ms). Useful for preview-like staging--region— pin to a specific region
When to Use Multi-Prod-Per-Project vs Separate Projects
Use multi-prod in one project when:
- Staging and production share the same team, IAM, billing, and codebase
- You want the same
convex/source pushed through staging then production - You want the dashboard, logs, and metrics for staging and prod side by side
- You're sharding production by region or tenant inside one logical service
Use a separate project for production when:
- Production must be isolated from non-prod team membership (e.g. you don't want non-admins able to switch between prod and staging by selecting a deployment ref)
- Billing/compliance requires fully separate Convex projects
- You want a strict trust boundary that the project itself enforces, not just role policies
For most teams, multi-prod in one project is now the simpler default.
Step-by-Step: Add a Staging Deployment to an Existing Project
1) Authenticate (once)
npx convex login
2) Make sure you're on the project you want
npx convex deployment select production # or your existing default prod
3) Create the staging deployment
npx convex deployment create staging --type prod
# Don't pass --select unless you want your laptop's active deployment to switch
# Don't pass --default — `production` should remain the default for `npx convex deploy`
4) Confirm in the dashboard that "staging" appears alongside "production"
5) Decide how staging gets deployed
Option A — manual:
CONVEX_DEPLOYMENT=<team>:<project>:staging npx convex deploy
Option B — CI pipeline with a deploy key (recommended):
# In dashboard: create a deploy key scoped to the staging deployment
# In CI:
CONVEX_DEPLOY_KEY=<staging-key> npx convex deploy
Option C — local agent inspection:
npx convex deployment select staging
# subsequent npx convex commands run against staging until you select anotherStep-by-Step: Targeting a Specific Deployment for One-off Commands
Per npx convex deployment select --help:
"You can also run individual commands on another deployment by using the --deployment flag on that command."Examples:
# Run a query against staging without switching active deployment
npx convex run myFunction --deployment staging
# Tail prod logs while keeping dev selected for hot reload
npx convex logs --deployment production
# Set an env var on staging
npx convex env set FOO bar --deployment staging--prod is a shortcut that targets the default prod (whatever was created with --default or your project's original production). It does not target a non-default named prod — use --deployment <ref> for those.
Step-by-Step: Production Sharding (Multiple Active Prods)
Use case: regional shards, tenant isolation, blue/green prod.
1) Create each shard
npx convex deployment create prod-eu --type prod --region eu
npx convex deployment create prod-us --type prod --region us
npx convex deployment create prod-apac --type prod
2) Pick one as the default for `npx convex deploy`
npx convex deployment create prod-eu --type prod --region eu --default
# (or set --default at create time on whichever is your primary)
3) Deploy to each shard via deploy keys in CI
# one CI job per shard, each with its own CONVEX_DEPLOY_KEY scoped to that shard
4) Application reads its own CONVEX_URL from .env.local / runtime config —
serve EU traffic from prod-eu's URL, US from prod-us, etc.Promotion Workflow (Dev -> Staging -> Prod)
1) Develop against dev (or per-worktree dev/<slug>)
2) Push to PR -> CI creates a preview/<branch> automatically (if preview keys configured)
3) Merge to main -> CI deploys to staging
CONVEX_DEPLOY_KEY=<staging-key> npx convex deploy
# Run smoke tests against the staging URL
4) Tag/release -> CI deploys to default prod
CONVEX_DEPLOY_KEY=<production-key> npx convex deployEach stage uses the same convex/ source. Schema, functions, and codegen go through unchanged; data is per-deployment.
Environment Variables and Naming
Convex doesn't impose a CONVEX_ENV value — that's an app convention. A common app-level pattern:
| Deployment | App-level CONVEX_ENV |
|---|---|
dev / dev/<slug> | dev |
preview/<branch> | preview |
staging (named prod) | staging |
production (default prod) | production |
prod-eu, prod-us (shards) | production (or production-<region>) |
Wire CONVEX_ENV from your CI pipeline / runtime config; gate cron jobs, autonomous workflows, and dangerous side effects on it (e.g. only run autonomous imports when CONVEX_ENV === "production").
Anti-Patterns
- Marking staging as `--default` — accidentally makes
npx convex deployship to staging when devs run it locally. Reserve--defaultfor the actual production deployment - Sharing one deploy key across prod deployments — defeats the point of the split; create a key per deployment
- Letting `--select` switch a CI runner's active deployment — CI should use
CONVEX_DEPLOY_KEYand--deployment, not modify.env.local - Treating `staging` as ephemeral — without
--expiration, named prod deployments are permanent. Set TTL only if you genuinely want auto-cleanup - Mixing per-worktree dev refs (`dev/<slug>`) with named prod refs in the same `.env.local` —
--selectoverwrites cleanly, but manual edits often leave stale URLs. Prefernpx convex deployment selectover hand-editing - Running migrations / data backfills against the wrong prod — always pass
--deployment <ref>explicitly for write operations against named prods, even if your active deployment is correct
Validation Checklist
- [ ]
npx convex deployment list(if available) or the dashboard shows the expected deployments - [ ] Default prod is the actual production (verify with
npx convex deployment select productionsucceeding without ambiguity) - [ ]
npx convex deploywithout flags ships to the intended default prod (dry-run first:npx convex deploy --dry-run) - [ ] Each named prod has its own deploy key in CI (one key per deployment)
- [ ]
CONVEX_DEPLOY_KEY=<staging-key> npx convex deploy --dry-runreports the right target - [ ] App-level
CONVEX_ENVis set per deployment and gates dangerous workflows - [ ] Local devs select dev / per-worktree dev refs, never staging or prod, for
npx convex dev - [ ] Cleanup plan exists for ephemeral named prods (either explicit
deployment deleteor--expiration)
See Also
- Per-worktree dev deployments:
references/parallel-worktrees.md - Migrations across stages:
references/migrations.md - Performance per-deployment:
references/performance.md
File Storage
Docs:
- File storage overview: https://docs.convex.dev/file-storage
Core ideas
- Store files in Convex file storage via
ctx.storage. - Store references/metadata (e.g.
storageId, content type, owner) in tables. - Restrict access: validate the current user and ensure they own the file or have permission.
Common patterns
- Upload flow:
- client requests an upload URL (or upload token)
- client uploads the file
- backend stores
storageId+ metadata
- Download/serve flow:
- backend checks permissions
- backend returns a signed URL or serves via an HTTP action
Safety
- Never log file contents.
- Treat file metadata as user-controlled unless validated.
- When deleting a record, decide whether to also delete the stored file.
Convex MCP (Model Context Protocol)
This skill strongly prefers Convex MCP for:
- accurate deployment introspection (functions/tables)
- structured logs
- safe, sandboxed one-off read queries
- environment variable management
Official docs: https://docs.convex.dev/ai/convex-mcp-server
MCP is optional
If MCP is not configured or not allowed in your environment, use:
npx convex devterminal logsnpx convex logsnpx convex run ...for manual verification- Convex Dashboard
When you can, enable MCP to make introspection and log verification faster.
Setup (for your agent)
Convex docs recommend running the MCP server via:
npx -y convex@latest mcp startConfiguration options (from Convex docs)
Run MCP server for a single project directory:
npx -y convex@latest mcp start --project-dir /path/to/projectDeployment selection examples:
- prod (requires explicit enablement):
--prodwith--dangerously-enable-production-deployments - preview:
--preview-name <name> - specific deployment:
--deployment-name <name> - env-file override:
--env-file <path>(uses.env.local/.envformat)
Disable tools (reduce blast radius):
npx -y convex@latest mcp start --disable-tools data,run,envSetProduction safety
By default, the MCP server cannot access production deployments. To enable prod access you must explicitly opt in:
npx -y convex@latest mcp start --dangerously-enable-production-deploymentsTreat this as dangerous: it can read/modify production data.
Deployment selection
From the Convex MCP docs:
- Default: development deployment
- Preview:
--preview-name <name> - Specific deployment:
--deployment-name <name> - Production:
--prodrequires--dangerously-enable-production-deployments
Always confirm the target deployment before running tools that mutate data (run, envSet, etc).
Tooling model
The MCP server typically returns an opaque deploymentSelector from status. Pass that selector unchanged into subsequent calls.
Recommended MCP workflow
1) Get deployments (first call)
convex_status({ projectDir: "/path/to/repo" })Pick the dev deployment selector by default.
2) Inspect API surface
convex_functionSpec({ deploymentSelector })3) Inspect schema/tables
convex_tables({ deploymentSelector })4) Verify logs before/after changes
convex_logs({ deploymentSelector })5) Run a function with args (confirm first)
convex_run({
deploymentSelector,
functionName: "path/to/module.ts:exportName",
args: JSON.stringify({})
})6) Run a sandboxed one-off read query (safe)
convex_runOneoffQuery({
deploymentSelector,
query: `import { query } from "convex:/_system/repl/wrappers.js";
export default query({
handler: async (ctx) => {
return await ctx.db.query("users").take(5);
},
});`
})Available MCP tools (common)
The official Convex MCP docs list tools in these categories:
- Deployment:
status - Tables/data:
tables,data,runOneoffQuery - Functions:
functionSpec,run,logs - Env vars:
envList,envGet,envSet,envRemove
Compatibility note
Different agents expose the Convex MCP tools under different names.
Use the conceptual tool names from the Convex docs (status/tables/functionSpec/run/logs/etc), then map them to whatever your agent provides.
Docs and components verification
Use MCP introspection to reduce guesswork:
functionSpecto confirm what is deployed (names, args, visibility)tablesto confirm table names and declared/inferred schema
Then verify the latest docs and components list:
- https://docs.convex.dev/
- https://docs.convex.dev/components
In this environment, those map to tools named:
convex_statusconvex_tablesconvex_dataconvex_runOneoffQueryconvex_functionSpecconvex_runconvex_logsconvex_envList/convex_envGet/convex_envSet/convex_envRemove
Migrations
Upstream canonical: prefer the convex-migration-helper skill from get-convex/agent-skills if installed, or WebFetch <https://raw.githubusercontent.com/get-convex/agent-skills/main/skills/convex-migration-helper/SKILL.md>. This file is the local fallback and supplements upstream with project conventions.
Docs: https://docs.convex.dev/database/schemas
Skip when: greenfield schema with no existing data, adding optional fields, adding new tables, or adding/removing indexes with no correctness concern.
Key Concepts
Convex will not deploy a schema that does not match data at rest. This drives the workflow:
- Cannot add a required field if existing documents lack it
- Cannot change a field type if existing documents have the old type
- Cannot remove a field if existing documents still have it
Safe Changes (No Migration Needed)
Adding optional field
// Before
users: defineTable({ name: v.string() })
// After -- safe
users: defineTable({ name: v.string(), bio: v.optional(v.string()) })Adding new table
posts: defineTable({
userId: v.id("users"),
title: v.string(),
}).index("by_user", ["userId"])Adding index
users: defineTable({ name: v.string(), email: v.string() })
.index("by_email", ["email"])Breaking Changes: Widen-Migrate-Narrow
Every breaking migration follows the same multi-deploy pattern:
Deploy 1 -- Widen the schema:
1. Update schema to allow both old and new formats 2. Update code to handle both formats when reading 3. Update code to write new format for new documents 4. Deploy
Between deploys -- Migrate data:
5. Run migration to backfill existing documents 6. Verify all documents migrated
Deploy 2 -- Narrow the schema:
7. Update schema to require new format only 8. Remove code that handles old format 9. Deploy
Using @convex-dev/migrations
For any non-trivial migration, use the migrations component. It handles batching, pagination, state tracking, resume from failure, dry runs, and progress monitoring.
Setup
npm install @convex-dev/migrations// convex/convex.config.ts
import { defineApp } from "convex/server";
import migrations from "@convex-dev/migrations/convex.config.js";
const app = defineApp();
app.use(migrations);
export default app;// convex/migrations.ts
import { Migrations } from "@convex-dev/migrations";
import { components } from "./_generated/api.js";
import { DataModel } from "./_generated/dataModel.js";
export const migrations = new Migrations<DataModel>(components.migrations);
export const run = migrations.runner();Define a migration
export const addDefaultRole = migrations.define({
table: "users",
migrateOne: async (ctx, user) => {
if (user.role === undefined) {
await ctx.db.patch(user._id, { role: "user" });
}
},
});Shorthand (return object = auto-patch):
export const clearField = migrations.define({
table: "users",
migrateOne: () => ({ legacyField: undefined }),
});Run a migration
npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}'Or programmatically:
await migrations.runOne(ctx, internal.migrations.addDefaultRole);Run multiple in order
export const runAll = migrations.runner([
internal.migrations.addDefaultRole,
internal.migrations.clearDeprecatedField,
]);Dry run
npx convex run migrations:runIt '{"dryRun": true}'Check status
npx convex run --component migrations lib:getStatus --watchCancel
npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}'Configuration
Custom batch size (large documents or heavy write traffic):
export const migrateHeavy = migrations.define({
table: "largeDocuments",
batchSize: 10,
migrateOne: async (ctx, doc) => { /* ... */ },
});Migrate subset using index:
export const fixEmpty = migrations.define({
table: "users",
customRange: (query) => query.withIndex("by_name", (q) => q.eq("name", "")),
migrateOne: () => ({ name: "<unknown>" }),
});Parallelize within batch:
export const clearField = migrations.define({
table: "myTable",
parallelize: true,
migrateOne: () => ({ optionalField: undefined }),
});Common Patterns
Adding a required field
// Deploy 1: allow both states
users: defineTable({
name: v.string(),
role: v.optional(v.union(v.literal("user"), v.literal("admin"))),
})
// Migration
export const addDefaultRole = migrations.define({
table: "users",
migrateOne: async (ctx, user) => {
if (user.role === undefined) {
await ctx.db.patch(user._id, { role: "user" });
}
},
});
// Deploy 2: make required
users: defineTable({
name: v.string(),
role: v.union(v.literal("user"), v.literal("admin")),
})Deleting a field
// Deploy 1: make optional
// isPro: v.boolean() --> isPro: v.optional(v.boolean())
// Migration
export const removeIsPro = migrations.define({
table: "teams",
migrateOne: async (ctx, team) => {
if (team.isPro !== undefined) {
await ctx.db.patch(team._id, { isPro: undefined });
}
},
});
// Deploy 2: remove isPro from schema entirelyChanging a field type
Prefer creating a new field:
// Deploy 1: add new field, keep old optional
// Migration: convert
export const convertToEnum = migrations.define({
table: "teams",
migrateOne: async (ctx, team) => {
if (team.plan === undefined) {
await ctx.db.patch(team._id, {
plan: team.isPro ? "pro" : "basic",
isPro: undefined,
});
}
},
});
// Deploy 2: remove isPro, make plan requiredSplitting nested data into a separate table
export const extractPreferences = migrations.define({
table: "users",
migrateOne: async (ctx, user) => {
if (user.preferences === undefined) return;
const existing = await ctx.db
.query("userPreferences")
.withIndex("by_user", (q) => q.eq("userId", user._id))
.first();
if (!existing) {
await ctx.db.insert("userPreferences", {
userId: user._id,
...user.preferences,
});
}
await ctx.db.patch(user._id, { preferences: undefined });
},
});Ensure code already writes to the new table for new users before running the migration.
Small table shortcut
For small tables (a few thousand documents), skip the component:
import { internalMutation } from "./_generated/server";
export const backfillSmall = internalMutation({
handler: async (ctx) => {
const docs = await ctx.db.query("smallConfig").collect();
for (const doc of docs) {
if (doc.newField === undefined) {
await ctx.db.patch(doc._id, { newField: "default" });
}
}
},
});Only use .collect() when certain the table is small.
Zero-Downtime Strategies
Dual write (preferred)
Write both formats, read old until migration completes. Safe to rollback at any point.
// Good: writing both structures during migration
export const createTeam = mutation({
args: { name: v.string(), isPro: v.boolean() },
handler: async (ctx, args) => {
const plan = args.isPro ? "pro" : "basic";
await ctx.db.insert("teams", {
name: args.name,
isPro: args.isPro,
plan,
});
},
});Dual read
Read both formats, write only new. Avoids duplicate writes but harder to rollback.
function getTeamPlan(team: Doc<"teams">): "basic" | "pro" {
if (team.plan !== undefined) return team.plan;
return team.isPro ? "pro" : "basic";
}Common Pitfalls
1. Making field required before migrating data -- Convex rejects the deploy. 2. Using `.collect()` on large tables -- Hits transaction limits. Use the migrations component. 3. Not writing new format before migrating -- Documents created during migration window get missed. 4. Skipping dry run -- Use dryRun: true to validate before production. 5. Deleting fields prematurely -- Prefer deprecating with v.optional + comment. 6. Using crons for batches -- The component handles batching internally.
Verification
export const verifyMigration = query({
handler: async (ctx) => {
const remaining = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("role"), undefined))
.take(10);
return {
complete: remaining.length === 0,
sampleRemaining: remaining.map((u) => u._id),
};
},
});Or use component status:
npx convex run --component migrations lib:getStatus --watchChecklist
- [ ] Identified breaking change and planned multi-deploy workflow
- [ ] Schema widened to allow both old and new formats
- [ ] Code handles both formats when reading
- [ ] Code writes new format for new documents
- [ ] Deployed widened schema
- [ ] Migration tested with
dryRun: true - [ ] Migration run and status monitored
- [ ] All documents verified migrated
- [ ] Schema narrowed to require new format only
- [ ] Old-format handling code removed
- [ ] Final deploy complete
- [ ] Migration code removed once stable
Parallel Worktree Development (Isolated Convex Backends)
Docs:
- Convex CLI overview: <https://docs.convex.dev/cli>
npx convex deployment --help(authoritative forselect/createsyntax)- Git worktree skill: see
git/SKILL.md"Worktrees"
Skip when: single worktree, single agent, no parallel dev.
Why
npx convex dev is a per-process watcher tied to a single deployment. Two worktrees pointing at the same CONVEX_DEPLOYMENT will:
- Race over
convex/_generated/codegen - Cross-pollinate reactive subscriptions between branches
- Overwrite each other's pushes on hot reload
To run multiple worktrees (or multiple agents) in parallel, give each its own backend.
TL;DR Decision Guide
| Situation | Pattern |
|---|---|
| Authenticated dev machine, multiple long-lived worktrees, want cloud dashboard + persistent data | A — Per-worktree cloud dev (`dev/<slug>`) |
| Cloud agent / CI / sandbox VM that cannot OAuth, ephemeral work | B — Anonymous local backend |
| One human, occasional second worktree (hotfix), don't need persistence in the secondary | A in primary, serialize watchers, or B in secondary |
Pattern A is recommended whenever the agent or developer can authenticate. It uses Convex's first-class named-deployment support and gives you cloud features (logs, dashboard, persistence). Pattern B is the fallback for environments without auth.
---
Pattern A — Per-Worktree Cloud Dev Deployment (Recommended)
Convex supports any number of named dev deployments per project via the dev/<slug> ref. Each worktree gets its own. Codegen, schema, data, and .env.local URLs are fully isolated. Standard cloud features (dashboard, logs, persistence) all work.
Authoritative CLI (verified via npx convex deployment --help)
npx convex deployment select <ref> # Switch active deployment
npx convex deployment create <ref> --type dev --select [--expiration "in 7 days"]
# Create a named dev deployment.
# --select also writes URLs to .env.localRefs accepted by select:
dev # Your personal default cloud dev deployment
local # Local deployment
dev/<name> # A named dev deployment in the current project
some-project:dev/<name> # Cross-project (same team)
some-team:some-project:dev/<name># Fully qualifiedSlug Derivation (Production-Tested Pattern)
A worktree's deployment slug should be:
- Deterministic — same worktree always resolves to the same slug
- Collision-resistant across machines — two devs with the same worktree name get different slugs
- Sanitized — Convex slugs are lowercase,
[a-z0-9-], max 48 chars
Recipe:
slug = sanitize(basename(worktree_path)) + "-" + sha1(hostname + ":" + abspath(worktree_path)).slice(0, 8)Where sanitize is:
toLowerCase()
replace(/[^a-z0-9]+/g, "-")
strip leading/trailing dashes
collapse multiple dashes
empty -> "dev"The 8-char SHA1 suffix is the collision guard; the readable prefix exists so npx convex deployment list is human-scannable. Clamp the prefix so the total stays ≤ 48 chars.
Step-by-Step: Onboard a Worktree
1) Create the worktree (use the git skill for env carry-over)
git worktree add ../my-feature -b feat/my-feature main
cd ../my-feature
2) Compute the slug
- basename: my-feature
- sha1("hostname:/abs/path/to/my-feature").slice(0,8): e.g. a3b4faf9
- slug: my-feature-a3b4faf9
- ref: dev/my-feature-a3b4faf9
3) Bootstrap project context (CRITICAL — easy to miss)
`npx convex deployment select|create` requires CONVEX_DEPLOYMENT to be
present in the environment to know which Convex team+project to scope
into. A fresh worktree's .env.local does not exist yet (gitignored), so
without bootstrap the CLI fails with: "No CONVEX_DEPLOYMENT set, run
`npx convex dev` to configure".
Seed only the CONVEX_DEPLOYMENT line from the primary worktree's
.env.local — do NOT copy secrets, URLs, or other keys. Primary path
pattern (adjust to your monorepo layout):
primary=$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')
primary_env="$primary/packages/backend/.env.local"
[ -f "$primary_env" ] || { echo "Run \`npx convex dev\` once in $primary first" >&2; exit 1; }
grep -E '^CONVEX_DEPLOYMENT=' "$primary_env" > packages/backend/.env.local
chmod 600 packages/backend/.env.local
If the primary itself has no CONVEX_DEPLOYMENT, error loudly with
actionable next steps — do not silently auto-provision.
4) (No-op for fresh worktrees) Strip inherited cloud bindings if the
worktree somehow has stale CONVEX_*/CONVEX_SITE_URL keys from a copied
env. The bootstrap above writes a clean one-line .env.local, so this
normally has nothing to do. Apply only when carrying env explicitly.
5) Try select first; fall back to create
# in the directory that contains convex.json
npx convex deployment select dev/my-feature-a3b4faf9 \
|| npx convex deployment create dev/my-feature-a3b4faf9 --type dev --select \
--expiration "in 14 days"
# --select rewrites .env.local with the worktree's CONVEX_DEPLOYMENT,
# CONVEX_URL, CONVEX_SITE_URL — the bootstrap line above is replaced.
# --expiration is the only Convex-supported auto-cleanup mechanism.
6) Generate types
npx convex dev --once # one-shot codegen, non-blocking (recommended for agents)
# or
npx convex dev # long-running watcher
7) Verify isolation
- convex/_generated/ exists in this worktree
- .env.local CONVEX_URL ends with the new deployment slug
- Cloud dashboard shows the new dev deployment
- Two worktrees can run dev simultaneously without a codegen raceConcurrency: Locking ensure-runs
If multiple processes (e.g. parallel agents on the same worktree) call the onboarding flow at once, they will race on deployment select/create. Wrap the ensure flow in a per-worktree advisory lock:
lock = path.join(backendDir, ".convex-dev-ensure.lock")
acquire(lock) { # write our PID to lock; if file exists, retry
for up to 30s:
try create(lock, "wx") with our PID
on EEXIST:
if (mtime > 60s old) or (process for stored PID is dead):
remove and retry
else:
sleep 100ms and retry
}
release(lock) { remove }This keeps the slow path (select -> create on miss -> --select rewrite of .env.local) safely serialized within one worktree without blocking other worktrees.
Cleanup When a Worktree Retires (CANONICAL ORDER)
Important truth check. npx convex deployment delete is not a real CLI subcommand. Run npx convex deployment --help to confirm — only select and create are exposed. Convex provides no public API to delete a cloud dev deployment either. The dashboard UI is the only way to remove the cloud-side deployment.
What you can automate:
1. Strip CONVEX_* keys from the worktree's .env.local (preserve unrelated keys like BETTER_AUTH_SECRET, RESEND_API_KEY) 2. Delete .env.local outright if nothing else remained 3. Print the dashboard URL for the cloud-side delete
Mandatory ordering — dev:remove MUST run before git worktree remove:
1) From inside the worktree, clear the local link
pnpm -F backend dev:remove # or your equivalent script
# -> strips CONVEX_DEPLOYMENT/CONVEX_URL/CONVEX_SITE_URL from .env.local
# -> preserves any unrelated keys (BETTER_AUTH_SECRET, RESEND_API_KEY, ...)
# -> prints the dashboard URL for the cloud-side delete
2) (Optional) Visit the printed dashboard URL → Settings → Delete deployment
Required for permanent cleanup. The CLI cannot do this.
Skip this step if you used --expiration at create time and the deployment
is allowed to expire on its own.
3) Remove the git worktree
cd <repo-root-or-primary>
git worktree remove <path>Why this order matters. Once the worktree directory is gone, dev:remove can no longer read its .env.local to discover the deployment ref, and the dashboard URL has to be reconstructed manually. Always run dev:remove first.
Rules:
- Refuse to act on the primary's `dev`. Detect with
git worktree list --porcelain— the first entry is the primary. Auxiliary worktrees are safe; the primary is not (itsdevis shared baseline). - Prefer
--expiration "in 7 days"(or 14, 30) at create time so forgotten worktrees self-clean on the cloud side. The expiration value is documented innpx convex deployment create --help. - Stale cloud deployments: if
dev:removewas skipped beforegit worktree remove, the cloud deployment lingers. Open the dashboard athttps://dashboard.convex.dev/t/<team>/<project>and delete entries matchingdev/*that no longer correspond to a live worktree.
Auth Failure Recovery
If select or create returns text matching not logged in, npx convex login, unauthorized, not authenticated, or auth token, surface a precise error:
Convex CLI is not authenticated. Run `npx convex login` from <backendDir>, then retry.Never silently swallow auth failures — they look identical to "deployment doesn't exist" if you aren't checking.
Reference Contract
A correct ensure-flow returns:
{
isAuxiliaryWorktree: boolean
worktreeName: string
deploymentRef: "dev" | "dev/<slug>"
deploymentSlug: string | undefined
cloudUrl: string # CONVEX_URL after --select
siteUrl: string # CONVEX_SITE_URL after --select
created: boolean # true if we just created vs reused
}Wire this to whatever launcher your stack uses (e.g. pnpm dev:stack, a Makefile, or the agent's worktree-bootstrap step). Run it before spawning the dev watcher, frontend dev server, or any process that reads CONVEX_URL.
Per-Worktree Port Allocation (Optional but Recommended)
When the worktree also runs a frontend or mobile dev server, allocate a deterministic port range per worktree to avoid EADDRINUSE:
worktreeIndex = position in `git worktree list --porcelain` # 0 = primary
stackPort = 41000 + worktreeIndex * 10
{ web: stackPort, mobile-web: stackPort+1, convex-local: stackPort+2, metro: stackPort+3 }stackPort is just a base offset; pick whatever band makes sense for your machine.
---
Pattern B — Anonymous Local Backend (Sandbox / CI / Headless)
CONVEX_AGENT_MODE=anonymous runs a fully local, no-auth Convex backend on the current machine. Use it when:
- The agent cannot OAuth (cloud sandbox, headless CI runner, ephemeral container)
- You want zero cloud footprint for throwaway work
- You explicitly want unshared, non-persistent state
Step-by-Step
1) Create the worktree
git worktree add ../sandbox-feature -b feat/sandbox main
cd ../sandbox-feature
2) Strip inherited cloud bindings (same as Pattern A step 3)
3) Opt into anonymous mode
echo 'CONVEX_AGENT_MODE=anonymous' >> .env.local
4) Generate types and start
npx convex dev --once # or: npx convex dev (watcher)
5) Verify
- Local backend URL printed (loopback / 127.0.0.1)
- convex/_generated/ exists
- No CONVEX_DEPLOYMENT pointing at *.convex.cloudWhat anonymous mode gives / doesn't give
| Gives | Doesn't give |
|---|---|
| No OAuth, fully local | Persistent cloud-stored data |
| Independent of any other worktree | Cloud dashboard / log UI |
| Schema, functions, codegen all work | Preview deployments |
| Safe for cloud agents and CI | Shared QA — no one else can connect |
Treat anonymous-mode data as ephemeral. It evaporates when the local backend stops.
---
Common Errors and How to Prevent Them
Error: "did not find convex.json / settings" or "please log in" inside a fresh worktree
Symptom. Right after git worktree add, you run npx convex dev (or any npx convex command) and the CLI:
- Complains it can't find Convex project linkage / "settings" / "convex.json"
- Prompts you to run
npx convex logineven though you're already logged in on this machine - Drops into an interactive first-run setup flow
Root cause. Convex authentication is global (~/.convex/config.json) and is shared across every worktree on the machine. But .env.local — which holds CONVEX_DEPLOYMENT and the deployment URLs — is gitignored and therefore is not carried into a new worktree by git worktree add. When npx convex dev cannot find CONVEX_DEPLOYMENT, it falls back to first-run setup, which involves an OAuth-style flow that looks like a re-login prompt.
Prevention (Mandatory)
Run the ensure-flow as the first Convex command in every new worktree, before npx convex dev or anything else. Crucially, the flow must prompt the user before silently creating a new cloud deployment when the worktree has no .env* files yet — auto-creation can leak unwanted dev deployments into the project, and an agent should never assume that's the user's intent.
Decision tree:
Is there any .env* file in the backend dir?
│
├─ YES, and CONVEX_DEPLOYMENT is set
│ -> Run `npx convex deployment select <ref-from-env>`; you're done.
│
├─ YES, but no CONVEX_DEPLOYMENT
│ -> Bootstrap CONVEX_DEPLOYMENT from primary's .env.local first
│ (write only that line — do NOT copy secrets/URLs).
│ Then run the slug-based ensure-flow:
│ try `deployment select dev/<slug>`,
│ on miss `deployment create dev/<slug> --type dev --select --expiration "in 14 days"`.
│
└─ NO .env* at all (fresh worktree, nothing carried)
-> STOP. Ask the user how they want to populate it. Don't auto-create.
Options to offer:
1. Create a new per-worktree dev deployment (`dev/<slug>`)
[will bootstrap CONVEX_DEPLOYMENT from primary, then select|create]
2. Paste an existing Convex deployment URL or ref
[will bootstrap from primary, then select that ref]
3. Copy `.env.local` from the primary worktree
[shares the primary's deployment — loses isolation]
Only proceed with option 1 (auto-create) after explicit confirmation.
In every branch, "bootstrap from primary" means: read $primary/packages/backend/.env.local,
extract its CONVEX_DEPLOYMENT line, write that single line to the worktree's .env.local
(chmod 600). This gives `npx convex deployment select|create` the team+project context
it needs to scope into. Without bootstrap, those commands fail with "No CONVEX_DEPLOYMENT
set". If the primary itself has no CONVEX_DEPLOYMENT, error loudly with actionable next
steps — point the user at `cd <primary>/packages/backend && npx convex dev` once.Why prompt instead of auto-creating? Three reasons:
- Cost / sprawl. Auto-creating per worktree without consent fills the project with stale
dev/*deployments. Cleanup is manual unless--expirationis set, and even then it leaks until expiry. - Wrong target. The user may actually want this worktree to hit shared dev, staging, or a sibling worktree's deployment — not a brand new one.
- Auth surprise. If the user is logged into the wrong Convex account, auto-create silently provisions in the wrong project.
Agent Rule (BLOCKING)
When operating autonomously in a fresh worktree:
1. Check for .env* files in the backend dir before any npx convex command. 2. If none exist, ask the user with the three options above. Quote what would happen for each. Wait for explicit choice. 3. If .env.local exists but lacks CONVEX_DEPLOYMENT, prefer the slug-based ensure-flow (option 1) but still mention it in chat so the user can override. 4. Never run deployment create without user confirmation in fresh-worktree contexts.
Interactive Bootstrap Script
A minimal wrapper that implements the decision tree, including the bootstrap-from-primary step that gives the Convex CLI project context. Suitable for a worktree post-create hook or pnpm dev:stack preflight:
#!/usr/bin/env bash
set -euo pipefail
# Run from within the worktree, after `git worktree add`.
# - Bootstraps CONVEX_DEPLOYMENT from primary's .env.local so the CLI has project context
# - Selects (or creates) a per-worktree dev/<slug> deployment
# - Prompts the user when no .env exists and primary cannot help
# - Fails closed (exit 2) in non-interactive shells when ambiguous
backend_dir="$(git rev-parse --show-toplevel)/packages/backend" # adjust to your layout
cd "$backend_dir"
# Locate primary worktree (always the first entry in `git worktree list --porcelain`)
primary="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
primary_env="$primary/packages/backend/.env.local"
env_files=( .env.local .env.development.local .env )
existing=()
for f in "${env_files[@]}"; do
[[ -f "$f" ]] && existing+=("$f")
done
has_deployment=0
if [[ ${#existing[@]} -gt 0 ]]; then
if grep -qE '^[[:space:]]*CONVEX_DEPLOYMENT=' "${existing[@]}"; then
has_deployment=1
fi
fi
# Per-worktree slug (deterministic, collision-resistant)
worktree_root="$(git rev-parse --show-toplevel)"
worktree_name="$(basename "$worktree_root")"
sanitized="$(printf '%s' "$worktree_name" \
| tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g; s/-{2,}/-/g')"
sanitized="${sanitized:-dev}"
sanitized="${sanitized:0:39}"
suffix="$(printf '%s:%s' "$(hostname)" "$(realpath "$worktree_root")" \
| sha1sum | awk '{ print substr($1, 1, 8) }')"
slug="${sanitized}-${suffix}"
ref="dev/${slug}"
# Skip bootstrap entirely on primary checkout
if [[ "$worktree_root" == "$primary" ]]; then
echo "Primary checkout — using shared 'dev' deployment. Nothing to do."
exit 0
fi
# Branch 1: env exists with CONVEX_DEPLOYMENT — just select it (idempotent re-attach)
if [[ ${#existing[@]} -gt 0 && $has_deployment -eq 1 ]]; then
current_ref="$(grep -E '^[[:space:]]*CONVEX_DEPLOYMENT=' "${existing[@]}" \
| head -n1 | sed -E 's/^[[:space:]]*CONVEX_DEPLOYMENT=//; s/^"//; s/"$//')"
echo "Existing deployment ref in env: $current_ref"
npx convex deployment select "$current_ref"
exit 0
fi
# Helper: bootstrap CONVEX_DEPLOYMENT-only line from primary's .env.local
bootstrap_from_primary() {
if [[ ! -f "$primary_env" ]]; then
cat >&2 <<EOF
Cannot bootstrap Convex project context for this worktree.
Reason: $primary_env not found.
Fix:
1. cd $primary
2. cd packages/backend && npx convex dev (pick a project once)
3. Re-run this script.
EOF
return 1
fi
if ! grep -qE '^CONVEX_DEPLOYMENT=' "$primary_env"; then
cat >&2 <<EOF
Cannot bootstrap Convex project context for this worktree.
Reason: $primary_env has no CONVEX_DEPLOYMENT line.
Fix: cd $primary/packages/backend && npx convex dev — then retry.
EOF
return 1
fi
grep -E '^CONVEX_DEPLOYMENT=' "$primary_env" > .env.local
chmod 600 .env.local
echo "Bootstrapped CONVEX_DEPLOYMENT from $primary_env into $backend_dir/.env.local"
}
# Branch 2: env exists but no CONVEX_DEPLOYMENT — bootstrap then ensure-flow
if [[ ${#existing[@]} -gt 0 && $has_deployment -eq 0 ]]; then
echo "Env file present but no CONVEX_DEPLOYMENT."
bootstrap_from_primary || exit 1
if ! npx convex deployment select "$ref" >/dev/null 2>&1; then
npx convex deployment create "$ref" --type dev --select --expiration "in 14 days"
fi
exit 0
fi
# Branch 3: no env at all — prompt, then bootstrap + select/create
cat <<EOF
No .env* file found in $backend_dir.
Choose how to populate Convex env for this worktree:
1) Create or attach a per-worktree dev deployment ($ref)
[bootstraps CONVEX_DEPLOYMENT from primary, then select|create dev/<slug>]
2) Paste an existing Convex deployment URL or ref
[requires bootstrap from primary first; will not auto-bootstrap if you cancel]
3) Copy .env.local from the primary worktree (loses isolation)
q) Quit and let me decide manually
EOF
if [[ ! -t 0 ]]; then
echo "Non-interactive shell. Refusing to auto-create. Re-run interactively or pre-populate .env.local." >&2
exit 2
fi
read -rp "Choice [1/2/3/q]: " choice
case "$choice" in
1)
bootstrap_from_primary || exit 1
if ! npx convex deployment select "$ref" >/dev/null 2>&1; then
npx convex deployment create "$ref" --type dev --select --expiration "in 14 days"
fi
;;
2)
bootstrap_from_primary || exit 1
read -rp "Paste deployment ref or URL: " ref_or_url
npx convex deployment select "$ref_or_url"
;;
3)
if [[ -f "$primary_env" ]]; then
cp "$primary_env" .env.local
echo "Copied $primary_env -> $backend_dir/.env.local"
echo "WARNING: this worktree now shares the primary's deployment. No isolation."
else
echo "Primary .env.local not found at $primary_env" >&2
exit 1
fi
;;
q|Q)
echo "Aborted. Nothing changed."
exit 0
;;
*)
echo "Invalid choice." >&2
exit 1
;;
esacSave as scripts/setup-convex-worktree.sh. Subsequent runs are idempotent: Branch 1 re-selects without recreating.
Teardown Script (Companion)
npx convex deployment delete does not exist. The script can only clear the local env link and surface the dashboard URL for the cloud-side delete. Save as scripts/teardown-convex-worktree.sh:
#!/usr/bin/env bash
set -euo pipefail
backend_dir="$(git rev-parse --show-toplevel)/packages/backend" # adjust to your layout
cd "$backend_dir"
worktree_root="$(git rev-parse --show-toplevel)"
primary="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
if [[ "$worktree_root" == "$primary" ]]; then
echo "Refusing to tear down: this is the primary checkout (shared 'dev' deployment)." >&2
exit 1
fi
env_file=".env.local"
[[ -f "$env_file" ]] || { echo "No .env.local — nothing to tear down."; exit 0; }
# Capture the ref before stripping so we can print the dashboard URL
ref="$(grep -E '^CONVEX_DEPLOYMENT=' "$env_file" | head -n1 \
| sed -E 's/^CONVEX_DEPLOYMENT=//; s/^"//; s/"$//' || true)"
# Strip CONVEX_* keys; preserve unrelated ones
tmp="$(mktemp)"
grep -vE '^(CONVEX_DEPLOYMENT|CONVEX_URL|CONVEX_SITE_URL|NEXT_PUBLIC_CONVEX_URL|EXPO_PUBLIC_CONVEX_URL|NEXT_PUBLIC_CONVEX_SITE_URL)=' "$env_file" > "$tmp" || true
if [[ -s "$tmp" ]]; then
mv "$tmp" "$env_file"
echo "Stripped CONVEX_* keys from $env_file (other keys preserved)."
else
rm -f "$tmp" "$env_file"
echo "Removed $env_file (no other keys remained)."
fi
if [[ -n "$ref" ]]; then
cat <<EOF
Cloud deployment is NOT deleted. The Convex CLI exposes no delete command;
do this manually in the dashboard:
https://dashboard.convex.dev/
Find the deployment matching: $ref
Settings -> Delete deployment.
If the deployment was created with --expiration, you can also let it expire
on its own.
EOF
fiMandatory ordering: run teardown-convex-worktree.sh BEFORE git worktree remove. Once the worktree directory is gone, .env.local is unreadable and you have to reconstruct the deployment ref manually.
Non-interactive contexts (CI, headless agents): the bootstrap script fails closed (exit 2) when stdin is not a TTY and no env exists. The agent should detect that exit code and surface the choice to its operator rather than retrying.
Error: "auth token expired" or genuine re-login required
Cause. ~/.convex/config.json access token is missing or revoked, so it actually does need a fresh login.
Fix. From any worktree on the machine: npx convex login. This updates the global config and benefits every worktree at once.
How to tell apart from the previous error. In the previous case, ~/.convex/config.json exists with a valid token; the issue is local to the worktree. In this case, the global config file is missing or its token rejected. The CLI's wording is similar in both, but only the global-config issue actually requires re-login — the worktree-local issue is fixed by running the ensure-flow.
Error: codegen race / "convex/_generated/ is out of date"
Cause. Two npx convex dev processes targeting the same deployment from different worktrees are pushing conflicting code.
Fix. Confirm each worktree resolves to its own dev/<slug> ref (run the ensure-flow), and that .env.local CONVEX_URL differs between worktrees. If two worktrees show the same URL, the slug derivation is non-deterministic or the suffix hash collided — re-derive using the recipe above.
Error: npx convex deploy shipped to staging instead of production (multi-prod setups)
See references/environments.md for the full multi-environment guide. Short answer: only one prod deployment can be --default; ensure that the production one (not staging) was created with --default, and that CI uses an explicit CONVEX_DEPLOY_KEY scoped to the right deployment.
Anti-Patterns
- Sharing `CONVEX_DEPLOYMENT` across worktrees — codegen race, stale
_generated/, cross-branch reactive invalidation - Two `npx convex dev` watchers against the same cloud deployment — last writer wins on push; subscriptions thrash
- Skipping the bootstrap step —
npx convex deployment select|createfails with "No CONVEX_DEPLOYMENT set" without project context. Always seedCONVEX_DEPLOYMENTfrom primary first - Copying primary's `.env.local` wholesale — drags secrets and stale URLs into the worktree. Bootstrap copies only the
CONVEX_DEPLOYMENTline;--selectthen writes the rest - Using only the worktree basename as the slug — two worktrees with the same name on different machines collide
- Calling `npx convex deployment delete` — that subcommand does not exist. Cloud-side delete is dashboard-only. Use
--expirationat create time or accept manual cleanup - Skipping `dev:remove` before `git worktree remove` — once the worktree directory is gone,
.env.localis unreadable and the deployment ref is lost; the dashboard URL has to be reconstructed manually - Touching the primary's `dev` deployment as part of cleanup — destroys the shared baseline; cleanup scripts must refuse when
worktree_root == primary - Treating anonymous-mode data as durable — it's not; do not rely on it for review, demos, or QA
- Running ensure flows in parallel without a lock —
selectthencreateis not atomic; concurrent runs duplicate-create or race on.env.localwrites - Hardcoding `CONVEX_URL` in committed env files — it must be derived per worktree; commit only the schema and function code
Validation Checklist
Onboarding (after git worktree add)
- [ ] Slug is deterministic and includes a host+path hash suffix
- [ ] Bootstrap step ran: worktree's
.env.localcontains exactly the primary'sCONVEX_DEPLOYMENTline (and only that, before--select) - [ ] Pattern A:
npx convex deployment select dev/<slug>succeeded, ORcreate --type dev --select [--expiration ...]ran once - [ ] Pattern B:
CONVEX_AGENT_MODE=anonymousset instead (sandbox / no-auth context) - [ ] After
--select:.env.localhasCONVEX_DEPLOYMENT,CONVEX_URL,CONVEX_SITE_URLmatching the slug; secrets are NOT polluted - [ ]
npx convex dev --once(ordev) completes without OAuth prompt for the chosen pattern - [ ]
convex/_generated/regenerated for this worktree - [ ] Auth-failure errors are surfaced with a clear "run
npx convex login" message - [ ] Ensure-flow is serialized per worktree via a lock file when called concurrently
- [ ] Two worktrees run their dev backends simultaneously with no codegen conflict and no state crossover
Teardown (before git worktree remove)
- [ ] Teardown script ran first;
git worktree removecame second - [ ] Worktree's
.env.localhadCONVEX_*keys stripped (or file deleted if it had no other keys) - [ ] Unrelated keys (
BETTER_AUTH_SECRET,RESEND_API_KEY, etc.) preserved - [ ] Dashboard URL printed for cloud-side delete (or
--expirationwas set at create time and self-cleanup is acceptable) - [ ] Primary's
devdeployment was NOT touched (script refuses on primary) - [ ] Cloud-side delete completed via dashboard for permanent cleanup, OR deployment is left to expire
Reference Implementation
A production-tested implementation of Pattern A (with bootstrap, locking, slug derivation, auth-failure recovery, and full test coverage) is reasonable as a 300-400 line Node script. Stages:
1. Detect worktree state (git worktree list --porcelain)
2. Skip everything if primary (worktree_root == primary)
3. Compute slug (basename + sha1(host:abspath).slice(0,8))
4. Resolve deploymentRef ("dev/<slug>" for auxiliary)
5. Acquire backend-dir lock (PID file with mtime staleness check)
6. Bootstrap project context (write only CONVEX_DEPLOYMENT line from primary's .env.local)
7. Try `deployment select <ref>`
8. On failure -> `deployment create <ref> --type dev --select [--expiration ...]`
9. Detect auth failures and rethrow with actionable message
10. Reload .env.local; return { ref, slug, cloudUrl, siteUrl, created }
11. Release lockTeardown script (separate entrypoint):
1. Refuse if primary checkout
2. Read CONVEX_DEPLOYMENT from .env.local before stripping (so we can print the dashboard URL)
3. Strip CONVEX_DEPLOYMENT/CONVEX_URL/CONVEX_SITE_URL/NEXT_PUBLIC_CONVEX_*/EXPO_PUBLIC_CONVEX_*
4. Preserve unrelated keys; delete the file if nothing else remained
5. Print dashboard URL for manual cloud-side deleteBoth scripts gate on isAuxiliaryWorktree (the first git worktree list --porcelain entry is the primary; everything else is auxiliary). The teardown script does NOT call any deployment delete CLI command — that command does not exist.
Authentication Patterns
For provider-specific setup (Convex Auth, Clerk, WorkOS, Auth0), see references/auth-setup.md.
Docs:
- Authentication overview: https://docs.convex.dev/auth
- Accessing auth in functions: https://docs.convex.dev/auth/functions-auth
Core Rules
- User identity comes from
ctx.auth.getUserIdentity(), not from args. - If you persist users, map identity subject ->
userstable via an index.
Wrapper Pattern (Recommended)
If you use wrappers, keep them small and explicit.
Example using convex-helpers custom functions:
import { v } from "convex/values";
import { ConvexError } from "convex/values";
import { customCtx, customQuery, customMutation } from "convex-helpers/server/customFunctions";
import { query, mutation } from "../_generated/server";
export const guestTokenArg = { guestToken: v.optional(v.string()) };
export const authQuery = customQuery(
query,
customCtx(async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new ConvexError({ code: "UNAUTHORIZED" });
const user = await ctx.db
.query("users")
.withIndex("by_subject", (q) => q.eq("subject", identity.subject))
.unique();
if (!user) throw new ConvexError({ code: "USER_NOT_FOUND" });
return { user };
})
);
export const authMutation = customMutation(
mutation,
customCtx(async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new ConvexError({ code: "UNAUTHORIZED" });
const user = await ctx.db
.query("users")
.withIndex("by_subject", (q) => q.eq("subject", identity.subject))
.unique();
if (!user) throw new ConvexError({ code: "USER_NOT_FOUND" });
return { user };
})
);Notes
- Keep auth checks at the top of public functions.
- If you support guests, treat guest tokens as optional and resolve them via an indexed lookup.
Function Templates
Docs:
- Functions overview: https://docs.convex.dev/functions
- Queries: https://docs.convex.dev/functions/query-functions
- Mutations: https://docs.convex.dev/functions/mutation-functions
- Actions: https://docs.convex.dev/functions/actions
- HTTP actions: https://docs.convex.dev/functions/http-actions
- Internal functions: https://docs.convex.dev/functions/internal-functions
- Validation: https://docs.convex.dev/functions/validation
- Runtimes: https://docs.convex.dev/functions/runtimes
Query with Returns Validator
import { query } from "../_generated/server";
import { v } from "convex/values";
import { itemDocValidator } from "./schemas";
export const get = query({
args: { id: v.id("items") },
returns: v.union(itemDocValidator, v.null()),
handler: async (ctx, args) => {
return await ctx.db.get("items", args.id);
},
});
export const list = query({
args: { userId: v.id("users"), limit: v.optional(v.number()) },
returns: v.array(itemDocValidator),
handler: async (ctx, args) => {
return await ctx.db
.query("items")
.withIndex("by_userId", (q) => q.eq("userId", args.userId))
.take(args.limit ?? 50);
},
});---
Mutation with Soft Delete
import { mutation } from "../_generated/server";
import { v } from "convex/values";
import { itemDocValidator } from "./schemas";
export const create = mutation({
args: { name: v.string() },
returns: v.id("items"),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
return await ctx.db.insert("items", {
name: args.name,
userId: identity.subject,
status: "pending",
createdAt: Date.now(),
});
},
});
export const archive = mutation({
args: { id: v.id("items") },
returns: v.null(),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const item = await ctx.db.get("items", args.id);
if (!item || item.userId !== identity.subject) {
throw new Error("Not found");
}
await ctx.db.patch("items", args.id, {
deletedAt: Date.now(),
});
return null;
},
});
export const listActive = query({
args: { userId: v.id("users") },
returns: v.array(itemDocValidator),
handler: async (ctx, args) => {
const items = await ctx.db
.query("items")
.withIndex("by_userId", (q) => q.eq("userId", args.userId))
.take(100);
return items.filter((item) => item.deletedAt === undefined);
},
});---
Action with Node.js
"use node";
import { internalAction } from "../_generated/server";
import { v } from "convex/values";
import { internal } from "../_generated/api";
export const processExternal = internalAction({
args: { itemId: v.id("items") },
returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => {
const result = await fetch("https://api.example.com/process", {
method: "POST",
body: JSON.stringify({ id: args.itemId }),
});
if (!result.ok) {
throw new Error(`API error: ${result.status}`);
}
await ctx.runMutation(internal.items.mutations.updateStatus, {
itemId: args.itemId,
status: "processed",
});
return { success: true };
},
});---
Void Returns Pattern
export const updateItem = mutation({
args: { id: v.id("items") },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.patch("items", args.id, { updatedAt: Date.now() });
return null;
},
});ESLint conflict: if using unicorn/no-null, add file-level disable:
/* eslint-disable unicorn/no-null -- Convex v.null() requires explicit null */Notes
- Prefer
.withIndex()over.filter(). Theconvex-rules/no-filter-on-queryESLint rule bans.filter()chained on query expressions. Array.filter()afterawait+.collect()is fine. - Keep reads bounded with
.take(n)or pagination. - Never use
ctx.db.get()orctx.db.query()inside a loop body (convex-rules/no-query-in-loop). UsePromise.all()with.map()for batch fetching instead. - All factory functions require a
returnsvalidator (convex-rules/require-returns-validator). - Queries go in
queries.ts, mutations inmutations.ts, actions inactions.ts(enforced byconvex-rules/namespace-separation). Seereferences/style.md.
Batch Fetch Pattern (Avoid N+1)
// Bad: N+1 query inside loop
for (const id of args.ids) {
const user = await ctx.db.get(id);
users.push(user);
}// Good: batch with Promise.all
const users = await Promise.all(
args.ids.map((id) => ctx.db.get(id))
);HTTP Actions
HTTP actions docs: https://docs.convex.dev/functions/http-actions
Pattern
Use httpRouter() and httpAction() to expose webhook endpoints.
Remember:
- validate signatures
- avoid logging secrets
- call internal mutations for sensitive writes
Minimal shape
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/health",
method: "GET",
handler: httpAction(async () => {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}),
});
export default http;Schema Patterns
Docs:
- Schema: https://docs.convex.dev/database/schemas
- Indexes: https://docs.convex.dev/database/reading-data/indexes
Dual Validators (Recommended)
IMPORTANT: Maintain BOTH data validator and document validator. Place validators in validators.ts within each scope folder (enforced by convex-rules/no-bare-v-any).
// <scope>/validators.ts
import { defineTable } from "convex/server";
import { v, Infer } from "convex/values";
export const itemValidator = v.object({
name: v.string(),
userId: v.id("users"),
status: v.union(v.literal("pending"), v.literal("active")),
createdAt: v.number(),
});
export type Item = Infer<typeof itemValidator>;
export const itemDocValidator = v.object({
_id: v.id("items"),
_creationTime: v.number(),
name: v.string(),
userId: v.id("users"),
status: v.union(v.literal("pending"), v.literal("active")),
createdAt: v.number(),
});
export type ItemDoc = Infer<typeof itemDocValidator>;
export const itemsTable = defineTable(itemValidator)
.index("by_userId", ["userId"])
.index("by_userId_createdAt", ["userId", "createdAt"])
.index("by_status", ["status"]);---
Discriminated Unions for Metadata
export const vTransactionMetadata = v.union(
v.object({ type: v.literal("usage"), rawUsageId: v.string(), model: v.optional(v.string()) }),
v.object({ type: v.literal("payment"), orderId: v.string(), provider: v.string() }),
v.object({ type: v.literal("refund"), originalId: v.id("transactions"), reason: v.string() }),
v.object({ type: v.literal("grant"), grantedBy: v.optional(v.id("users")), reason: v.string() })
);---
Index Naming Convention
by_userId -> ["userId"]
by_userId_createdAt -> ["userId", "createdAt"]
by_status_priority -> ["status", "priority"]
by_owner -> ["ownerType", "ownerId"]---
Junction Tables (Many-to-Many)
export const trackGenresTable = defineTable({
trackId: v.id("tracks"),
genreId: v.id("genres"),
})
.index("by_track", ["trackId"])
.index("by_genre", ["genreId"]);---
Project Structure (Enforced by @vllnt/eslint-config)
convex/
lib/
validators.ts shared v.any() aliases
<scope>/
queries.ts query(), internalQuery()
mutations.ts mutation(), internalMutation()
internal_mutations.ts internalMutation() (optional split)
actions.ts action(), internalAction()
validators.ts v.* validators + types
schema.ts table definitions
workflows.ts
tests/
migrations/ relaxed namespace rules
schema.ts
convex.config.ts
auth.config.ts
crons.ts
http.ts
_generated/ excluded from lintingsnake_case filenames required (e.g. user_helper.ts, not user-helper.ts). Config files exempt.
Scheduling, Workflows, Workpools
Scheduling docs: https://docs.convex.dev/scheduling
Rule
Schedule internal functions, not public api.* references.
Options
- Built-in scheduled functions + crons for simple jobs.
- Components (workpool/workflow) for higher-level durability/retry/priority.
Component docs:
- https://docs.convex.dev/components
Workpool (rate limiting / serialization)
This is useful when calling rate-limited external APIs from actions.
import { defineApp } from "convex/server";
import workpool from "@convex-dev/workpool/convex.config";
const app = defineApp();
app.use(workpool, { name: "externalApi" });
export default app;Workflow (durable multi-step)
Workflows are useful for long-running, multi-step jobs with retries/delays.
See also: references/ecosystem.md.
Keep it up to date
Convex components evolve. Before choosing an approach, re-check:
- https://docs.convex.dev/components
- https://stack.convex.dev/
Performance Audit
Upstream canonical: prefer the convex-performance-audit skill from get-convex/agent-skills if installed, or WebFetch <https://raw.githubusercontent.com/get-convex/agent-skills/main/skills/convex-performance-audit/SKILL.md>. This file is the local fallback and supplements upstream with project conventions.
Docs: https://docs.convex.dev/understanding/best-practices/
Skip when: initial setup, auth setup, component extraction, pure schema migration, or micro-optimization without a user-visible problem.
Guardrails
- Prefer simpler code when scale is small, traffic is modest, or signals are weak
- Do not recommend digest tables, document splitting, or migration-heavy rollouts without a measured signal or clearly unbounded path
- A simple scan on a small table is often acceptable in Convex
Step 1: Gather Signals
Start with the strongest signal available:
1. Deployment Health insights (if available from user/context) 2. CLI: npx convex insights --details (use --prod, --preview-name, or --deployment-name as needed)
- If CLI too old:
npx -y convex@latest insights --details
3. Convex MCP logs (if available) 4. Code audit (if no runtime signals -- keep guardrails in mind)
Step 2: Signal Routing
| Signal | Section |
|---|---|
| High bytes/documents read, JS filtering, unnecessary joins | Hot Path Rules |
| OCC conflict errors, write contention, mutation retries | OCC Conflicts |
| High subscription count, slow UI updates, excessive re-renders | Subscription Cost |
| Function timeouts, transaction size errors, large payloads | Function Budgets |
| General "it's slow" with no specific signal | Start with Hot Path Rules |
Multiple problem classes can overlap. Read the most relevant section first.
Step 3: Scope and Trace
Pick one concrete user flow. Write down:
- Entrypoint functions
- Client callsites (
useQuery,usePaginatedQuery,useMutation) - Tables read and written
- Whether the path is high-read, high-write, or both
For each function, trace every ctx.db.get(), ctx.db.query(), ctx.db.patch(), ctx.db.replace(), ctx.db.insert(). Note foreign-key lookups, JS-side filtering, and full-document reads.
Step 4: Fix Sibling Functions Together
When one function has a performance bug, audit sibling functions for the same pattern. Do not leave one path fixed and another on the old pattern.
---
Hot Path Rules
Core principle: every byte read or written multiplies with concurrency.
cost x calls_per_second x 86400
In Convex, every write can fan out into reactive invalidation and downstream sync.
1. Push filters to storage
Both JavaScript .filter() and Convex query .filter() mean you already paid for the read. Only .withIndex() and .withSearchIndex() reduce documents scanned. [eslint: convex-rules/no-filter-on-query bans .filter() chained on query expressions]
// Bad: scans then filters
const tasks = await ctx.db.query("tasks").collect();
return tasks.filter((task) => task.status === "open");// Also bad: Convex .filter() does not push to storage
return await ctx.db.query("tasks")
.filter((q) => q.eq(q.field("status"), "open"))
.collect();// Good: index does the filtering
return await ctx.db.query("tasks")
.withIndex("by_status", (q) => q.eq("status", "open"))
.collect();Index migration rule: undefined !== false in Convex. If older documents are missing a field, they will not match a compound index entry that expects false. Verify backfill status before trusting indexes on optional fields. See references/migrations.md for safe rollout.
Check for redundant indexes: by_foo and by_foo_and_bar are usually redundant (keep only the compound). Exception: if you need results sorted by foo then _creationTime, the single-field index is needed.
2. Minimize data sources
If a function resolves a foreign key for a tiny display field and a denormalized copy exists, prefer it on the hot path.
Denormalize when:
- Path is hot
- Joined document is much larger than the field needed
- Many readers pay that join cost repeatedly
Fallback rule: denormalized data is an optimization, live data is the correctness path. If the denormalized field is missing, fall back to the live read.
// Bad: missing denormalized data becomes a placeholder
const ownerName = project.ownerName ?? "Unknown owner";// Good: fall back to live read
const ownerName =
project.ownerName ??
(await ctx.db.get(project.ownerId))?.name ??
null;3. Minimize row size (digest tables)
When list queries only need a few fields but documents are large, consider a companion digest table with just the fields needed for listing.
4. Skip no-op writes
Every ctx.db.patch() triggers reactive invalidation even if data is unchanged.
// Bad: always writes, even when unchanged
await ctx.db.patch(doc._id, { status: newStatus });// Good: skip when unchanged
if (doc.status !== newStatus) {
await ctx.db.patch(doc._id, { status: newStatus });
}5. Match consistency to read patterns
- High-read / low-write: denormalize aggressively, digest tables, pre-computed aggregates
- High-read / high-write: isolate frequently-updated fields into separate documents to minimize invalidation blast radius
---
OCC Conflicts
Convex uses Optimistic Concurrency Control. When two transactions read and write overlapping data, one is retried. Under load, this becomes contention.
Symptoms
- OCC conflict errors in logs
- Mutation retries visible in dashboard/insights
- Timeouts under concurrent writes
Common causes and fixes
Hot document (single counter, global config updated frequently):
// Bad: single counter document updated by every request
await ctx.db.patch(counterId, { count: current.count + 1 });Fix: use @convex-dev/sharded-counter to spread writes across shards.
Wide read set (query reads many documents, mutation touches one):
Fix: narrow query scope with tighter indexes, smaller read window (.take(n)), or move reads to a digest/summary table.
Competing writers on same row:
Fix: design mutations to touch fewer shared rows. Use per-user or per-session documents instead of shared ones where possible.
When to escalate
If the fix requires document splitting, summary tables, or migration-heavy changes, present options to the user before editing. See references/migrations.md for safe rollout patterns.
---
Subscription Cost
Every reactive query (useQuery) is a live subscription. More subscriptions = more work on every relevant write.
Symptoms
- Slow UI updates
- Excessive re-renders
- Dashboard shows high subscription count
Fixes
Too many subscriptions per page:
Fix: consolidate related queries. One query returning a structured object is cheaper than five returning fragments.
Queries returning too much data:
Fix: return only what the UI needs. Use .take(n), pagination, or project fewer fields (digest table pattern).
Point-in-time reads instead of subscriptions:
If the data does not need live updates (e.g., user settings loaded once), use a one-shot fetch instead of a subscription where the framework supports it.
Subscription invalidation amplification:
If a write to table A invalidates 100 subscriptions, the write fan-out is expensive. Fix: narrow subscription read sets (tighter indexes, smaller tables, digest tables).
---
Function Budgets
Convex has execution and transaction limits. Hitting these means the function is doing too much work.
Symptoms
- Function timeout errors
- Transaction size exceeded
- "Too many documents read" errors
- Large payload errors
Fixes
Too many documents read in one transaction:
Fix: add indexes to reduce scan width. Use .take(n) or pagination. If you must process many documents, use an action with batched reads via scheduled mutations.
Large documents:
Fix: split large blobs into separate documents or use file storage. Keep frequently-read documents lean.
Large return payloads:
Fix: return only the fields the client needs. Consider a digest table for list endpoints.
Long-running computation:
Fix: move heavy computation to an action (runs outside the transaction). Use "use node" for CPU-intensive work.
---
Verification
After applying fixes:
1. Results are the same -- no dropped records 2. Eliminated reads/writes are no longer in the path 3. Fallback behavior works when denormalized/indexed fields are missing 4. New writes avoid unnecessary invalidation when data unchanged 5. Every relevant sibling reader/writer was inspected
Checklist
- [ ] Gathered signals from insights, dashboard, or code audit
- [ ] Identified the problem class
- [ ] Scoped one concrete user flow
- [ ] Traced every read and write in the path
- [ ] Identified sibling functions touching same tables
- [ ] Applied fixes following recommended order
- [ ] Fixed sibling functions consistently
- [ ] Verified behavior and no regressions
Quickstart
Upstream canonical: prefer the convex-quickstart skill from get-convex/agent-skills if installed, or WebFetch <https://raw.githubusercontent.com/get-convex/agent-skills/main/skills/convex-quickstart/SKILL.md>. This file is the local fallback and supplements upstream with project conventions.
Docs: https://docs.convex.dev/quickstart
Skip when: project already has convex/ directory and CONVEX_DEPLOYMENT configured.
Path 1: New Project (Recommended)
Use the official scaffolding tool:
npm create convex@latest my-app -- -t <template>
cd my-app
npm installTemplates
| Template | Stack |
|---|---|
react-vite-shadcn | React + Vite + Tailwind + shadcn/ui |
nextjs-shadcn | Next.js App Router + Tailwind + shadcn/ui |
react-vite-clerk-shadcn | React + Vite + Clerk auth + shadcn/ui |
nextjs-clerk | Next.js + Clerk auth |
nextjs-convexauth-shadcn | Next.js + Convex Auth + shadcn/ui |
nextjs-lucia-shadcn | Next.js + Lucia auth + shadcn/ui |
bare | Convex backend only, no frontend |
Default: react-vite-shadcn for simple apps, nextjs-shadcn for SSR/API routes.
Custom GitHub template:
npm create convex@latest my-app -- -t owner/repo
npm create convex@latest my-app -- -t owner/repo#branchTo scaffold in the current (empty) directory:
npm create convex@latest . -- -t react-vite-shadcn
npm installStart the Dev Loop
npx convex dev is a long-running watcher that requires browser-based OAuth on first run. Ask the user to run it themselves. Once running it will:
- Create a Convex project and dev deployment
- Write the deployment URL to
.env.local - Create
convex/with generated types - Watch for changes and sync continuously
Exception: cloud/headless agents should use Agent Mode (see below).
Start frontend in a separate terminal:
npm run devWhat You Get
my-app/
convex/ # Backend functions and schema
_generated/ # Auto-generated types (check into git)
schema.ts # Database schema
src/ # Frontend (or app/ for Next.js)
package.json
.env.local # Deployment URL env varPath 2: Add Convex to Existing App
Install
npm install convexAsk the user to run npx convex dev to initialize.
Wire Up the Provider
Create ConvexReactClient at module scope, not inside a component.
React (Vite)
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import App from "./App";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ConvexProvider client={convex}>
<App />
</ConvexProvider>
</StrictMode>,
);Next.js (App Router)
// app/ConvexClientProvider.tsx
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}// app/layout.tsx
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}Other Frameworks
Environment Variables
| Framework | Variable |
|---|---|
| Vite | VITE_CONVEX_URL |
| Next.js | NEXT_PUBLIC_CONVEX_URL |
| Remix | CONVEX_URL |
| React Native | EXPO_PUBLIC_CONVEX_URL |
npx convex dev writes the correct variable to .env.local automatically.
Agent Mode (Cloud and Headless Agents)
Set CONVEX_AGENT_MODE=anonymous for environments that cannot open a browser for login:
CONVEX_AGENT_MODE=anonymous npx convex devAdd to .env.local or set inline. Runs a local anonymous deployment without authentication.
Verify Setup
1. User confirms npx convex dev running without errors 2. convex/_generated/ exists with api.ts and server.ts 3. .env.local contains deployment URL
First Function (Smoke Test)
convex/schema.ts:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
tasks: defineTable({
text: v.string(),
completed: v.boolean(),
}),
});convex/tasks.ts:
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("tasks").collect();
},
});
export const create = mutation({
args: { text: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("tasks", { text: args.text, completed: false });
},
});Usage in React:
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
function Tasks() {
const tasks = useQuery(api.tasks.list);
const create = useMutation(api.tasks.create);
return (
<div>
<button onClick={() => create({ text: "New task" })}>Add</button>
{tasks?.map((t) => <div key={t._id}>{t.text}</div>)}
</div>
);
}Dev vs Production
npx convex devfor development (personal dev deployment, syncs on save)npx convex deployfor production (separate deployment, do not use during dev)
Next Steps
- Add auth:
references/auth-setup.md - Design schema:
references/patterns/schemas.md - Build components:
references/components.md - Plan migrations:
references/migrations.md
Style (TSDoc + No Inline Comments)
Goals:
- Make Convex backend code self-documenting.
- Keep documentation in TSDoc, not scattered inline comments.
Rules
- Every exported Convex function must have TSDoc.
- Every exported non-trivial type (shared across files) should have TSDoc.
- Avoid non-TSDoc comments (
//and/* ... */) in backend code. - Exception: directive comments required by tooling (e.g. runtime directives) and ESLint disables.
TSDoc Template
/**
* One-line summary of what this function does.
*
* Preconditions:
* - Authentication required (or not)
* - Any invariants
*
* @param ctx - Convex context
* @param args - Validated input
* @returns Validated output
*/File Organization (Enforced by @vllnt/eslint-config)
Namespace separation: each function type MUST live in its designated file. This is enforced by convex-rules/standard-filenames and convex-rules/namespace-separation.
convex/<scope>/
queries.ts query(), internalQuery()
mutations.ts mutation(), internalMutation()
internal_mutations.ts internalMutation() (optional split)
actions.ts action(), internalAction()
validators.ts v.* validators + types
schema.ts table definitions
workflows.ts
crons.ts
tests/Naming Rules
- snake_case filenames in
convex/(enforced byconvex-rules/snake-case-filenames). Example:user_helper.ts, notuser-helper.ts. - Config files exempt:
auth.ts,auth.config.ts,convex.config.ts. - Migration files exempt from namespace separation.
Namespace Rules
query()/internalQuery()ONLY inqueries.tsmutation()/internalMutation()ONLY inmutations.tsorinternal_mutations.tsaction()/internalAction()ONLY inactions.ts
Validator Rules
- No bare
v.any()outsidevalidators.ts(enforced byconvex-rules/no-bare-v-any). Define named aliases instead:
// validators.ts
export const IdInput = v.any();
// queries.ts -- use the alias
import { IdInput } from "./validators";
export const myQuery = query({
args: { id: IdInput },
handler: async (ctx, args) => { /* ... */ },
});Testing Patterns
Docs:
- Overview: https://docs.convex.dev/testing
- convex-test: https://docs.convex.dev/testing/convex-test
- Run functions from CLI (manual tests): https://docs.convex.dev/cli#run-convex-functions
Co-located Tests (Preferred)
Keep tests close to the functions they validate:
convex/<scope>/tests/Naming Convention (Suggested)
Use a clear, scoped naming pattern:
<scope>.<type>.<case>.test.tsExamples:
categories.mutations.create.test.ts
auth.queries.listAvatars.test.ts
wordzic.workflows.lifecycle.test.tsStrategies
Pick one per repo (or clearly separate them):
1) convex-test unit/integration tests in JS (fast, isolated) 2) End-to-end tests against a live dev deployment (slower, higher confidence)
Live Deployment Tests (Pattern)
If you test against a live dev deployment:
- Keep tests under
convex/<scope>/tests/. - Use a shared test harness to call functions (HTTP/WebSocket client) and to seed fixtures.
- Run tests serially if the suite mutates shared tables.
- Add
test_supporthelpers when you need to exercise internal functions from tests.
Rules
- Test auth paths: authenticated and unauthenticated.
- Keep reads bounded (
take/pagination) to avoid flaky timeouts. - Prefer index-backed queries and assert shape and ordering.
- Enforce TSDoc on exported functions and avoid non-TSDoc comments.
Related skills
FAQ
Does it use the official Convex skills?
Yes. For canonical content it delegates to the official get-convex/agent-skills collection, either installed locally or fetched from the raw SKILL.md URLs, falling back to local references.
What are its blocking rules?
Docs-first (verify latest official docs before implementing) and a core rule to never ship Convex changes without verifying runtime behavior via MCP logs, convex dev logs, or the dashboard.