← Files ConvexARCHIVED FILE
skills/convex-expert/SKILL.md
18.4 KB · Oct 3, 2026 · 06:03 UTC
---
name: "convex-expert"
description: "Convex backend rules — consult this whenever writing or editing any code inside a convex/ directory (schemas, queries, mutations, actions, HTTP endpoints, crons, file storage, auth, component installation). TRIGGER before touching convex/ functions, so the code uses the object-form syntax, validators, indexes, and component patterns that generic models get wrong."
license: "Apache-2.0"
---
You are a Convex backend specialist. You write Convex code that runs the first time. Generic Codex reliably ships Convex code with the wrong function syntax, missing validators, `.filter()` instead of indexes, and custom `messages` tables instead of `@convex-dev/agent`. You don't.
Your job: write or review code inside a Convex project's `convex/` directory. When invoked, read the task carefully, **read the project's `convex/schema.ts` first** (and `convex/_generated/ai/guidelines.md` if present), then act.
## Data access + imports — read before writing
Front-loaded, not a post-hoc lint. These are the highest-frequency mistakes and each one is either a hard deploy failure or the #1 perf footgun:
- **Never an unbounded `.collect()` on a table that can grow.** Use `.withIndex(...)` combined with `.paginate(paginationOpts)` or `.take(n)`. `.collect()` on a large indexed query is the single most common Convex defect — it works fine at 10 rows and dies at 10,000 (`Too many reads in a single function execution`).
- **Index, don't filter.** Add `.index(...)` in `schema.ts` for every read path and query it with `.withIndex(...)`. `.filter()` is a full table scan — never a substitute for a SQL `WHERE`.
- **The exact import table** — get this wrong and the app fails to deploy:
| Symbol | Import from |
|---|---|
| `query`, `mutation`, `action`, `internalQuery`, `internalMutation`, `internalAction` | `"./_generated/server"` |
| `api`, `internal` | `"./_generated/api"` |
`import { query } from "convex/server"` and `import { internal } from "./_generated/server"` are both hard deploy failures — `convex/server` is the framework package, not your generated codegen.
- **`v.literal("exact value")`** for a fixed string/enum member — e.g. `v.union(v.literal("open"), v.literal("closed"))` — not a bare `v.string()` when the set of values is fixed.
- **`"use node";` is action-only.** It goes at the top of a module that exports only `action`s. A file with `"use node"` can never also export a `query` or `mutation` — they don't run in the Node runtime. Split the file if you need both.
- **Convex functions only run from the `convex/` directory.** Never write `schema.ts`, queries, mutations, or actions at the project root — they silently never deploy.
## Self-verify — before declaring backend work done
Before you call any backend work finished, verify it actually compiles and pushes:
1. Run `npx tsc --noEmit`.
2. When a deployment is available — or via a local anonymous one, `CONVEX_AGENT_MODE=anonymous npx convex dev --once` — push it.
**Fix every error either one reports before finishing.** One verify round catches the class of defect that otherwise breaks the deploy after you've already reported success: a wrong relative import, a duplicate symbol, an unbalanced paren. A model that "looks done" in the diff is not the same as a model that has been pushed.
## Non-negotiable rules
### Function syntax — object form, validators, returns
```ts
import { v } from "convex/values";
import { query, mutation, action } from "./_generated/server";
export const listOpen = query({
args: { limit: v.optional(v.number()) },
returns: v.array(
v.object({
_id: v.id("tickets"),
_creationTime: v.number(),
title: v.string(),
}),
),
handler: async (ctx, args) => {
const rows = await ctx.db
.query("tickets")
.withIndex("by_state", (q) => q.eq("state", "open"))
.order("desc")
.take(args.limit ?? 10);
return rows.map((r) => ({ _id: r._id, _creationTime: r._creationTime, title: r.title }));
},
});
```
- **Object form only.** Never the legacy positional `query(args, handler)`.
- **`args` and `returns` validators on every registered function**, internal or public. No exceptions. They are runtime guards, not type hints.
- **`v.id(tableName)`** for IDs, never `v.string()`.
- **`undefined` is not a Convex value.** Use `null`. Optional fields use `v.optional(...)`.
### Internal vs public
- Public `query` / `mutation` / `action` = anything the client calls directly. Public surface is a liability.
- Helpers, scheduled callbacks, internal business logic = `internalQuery` / `internalMutation` / `internalAction`.
- Default to internal. Promote to public only when a `useQuery` / `useMutation` / `useAction` on the client needs it.
### Indexes — name after the columns, in order
```ts
defineTable({ author: v.string(), channel: v.string(), text: v.string() })
.index("by_author_and_channel", ["author", "channel"]);
```
- **Add an index for every read path.** Never `.filter()` for anything you'd put in a SQL `WHERE`. Use `withIndex(...)`.
- Name indexes after the columns in order: `by_author_and_channel` for `["author", "channel"]`.
- **Never include `_creationTime` as a column in a custom index.** Convex appends it automatically. Writing `["author", "_creationTime"]` errors at push as `IndexNameReserved`.
### Schema evolution
- **Add new fields as `v.optional(...)`** when the table has data. Required fields on existing rows = `Schema validation failed` on push.
- Once backfilled, tighten back to required (re-push; Convex re-validates).
- **Beware the required-field deadlock.** Adding a *required* field to a populated table fails the push — and a failed push blocks **ALL** function deploys, including the very cleanup/backfill mutation you'd write to fix it. Don't paint yourself into this corner: either widen→migrate→narrow (add it `v.optional`, backfill or clear rows, *then* make it required) or wipe the table first via `npx convex import --replace` of an empty file. Never add a bare required field to a table that already has rows.
- Schema errors show up in `convex dev` stdout. Read the message; don't guess.
- **dev→prod data migration:** use a full-snapshot `npx convex export` → `npx convex import --replace` (not per-table — that re-ids rows and breaks foreign keys; snapshot import preserves `_id`). Carry the `users`/`auth*` tables too so ownership resolves. Use `--replace`, not `--replace-all`, if any component (e.g. `@convex-dev/static-hosting`) has tables in the snapshot you don't want wiped.
### Resource limits — design around them
| Limit | Value |
|---|---|
| Reads per function | ~16,000 documents |
| Writes per function | ~8,000 documents |
| Single document | 1 MiB |
| Total payload | 8 MiB |
| Query CPU | ~1 second |
| Action runtime | 10 minutes |
Hitting a limit = redesign, not retry. Paginate (`paginationOptsValidator` + `.paginate`), batch via `ctx.scheduler`, or use `@convex-dev/workpool` for bounded concurrency.
### React/client patterns
- **`useQuery` is reactive.** Never wrap it in `useEffect` to refetch.
- **Conditional fetches use `"skip"`**: `useQuery(api.foo.bar, shouldFetch ? args : "skip")`.
- **Mutations are transactional.** Don't lock rows manually. OCC handles conflicts; if `OCC conflict` errors appear, reduce write contention (sharded counters via `@convex-dev/aggregate`).
### Auth
- `await ctx.auth.getUserIdentity()` in any function that requires login. Returns `null` if unauthenticated — handle both branches.
- Don't roll your own `users`/`sessions`/`accounts` tables. Use Convex Auth or WorkOS plus a thin `users` table keyed by `tokenIdentifier`.
- **Setting up Convex Auth? `convex/auth.config.ts` is MANDATORY — emit it every time, same turn as `auth.ts`.** It is the single most-skipped file and its absence is the worst possible failure mode: sign-up/sign-in *succeed* server-side and tokens get minted, but `getAuthUserId(ctx)` / `ctx.auth.getUserIdentity()` return `null` on every request because the deployment has no registered JWT issuer. The app looks permanently "signed out" — queries return `[]`, seeds throw "not signed in", and **nothing errors anywhere**. Auth is not wired until this file exists next to `auth.ts`, `http.ts`, and `authTables`:
```ts
// convex/auth.config.ts
export default {
providers: [{ domain: process.env.CONVEX_SITE_URL, applicationID: "convex" }],
};
```
- **Convex Auth needs `JWT_PRIVATE_KEY` / `JWKS` / `SITE_URL` set on the deployment** — and these are **per-deployment: they do NOT carry from dev to prod.** Set them again on prod with/before the first prod deploy. Symptom of missing keys: sign-in throws `TypeError: Cannot read properties of null (reading 'redirect')`. Generate/set via `npx @convex-dev/auth --skip-git-check --web-server-url <url>`. When setting a multi-line PEM by hand, pass it as `"$(cat key.pem)"` — `npx convex env set --prod JWT_PRIVATE_KEY "<pasted-pem>"` silently mangles the newlines and the var ends up unset (no error; only `env list` reveals it).
### File storage
- Store the `Id<"_storage">` in tables, **not** the URL. URLs expire.
- Fetch the URL on read: `await ctx.storage.getUrl(storageId)`.
## Component-first reflexes
Before writing custom code, check https://www.convex.dev/components. Reach for these without thinking:
### Chat / LLM → `@convex-dev/agent`
Any chat panel, agent loop, or LLM call — even "just one `Anthropic.messages.create`". Within two follow-ups you'll need threads, history, tool use, streaming, retries. A custom `messages` table is the wrong answer.
```ts
// convex/convex.config.ts
import { defineApp } from "convex/server";
import agent from "@convex-dev/agent/convex.config";
const app = defineApp();
app.use(agent);
export default app;
// convex/chat.ts
import { Agent } from "@convex-dev/agent";
import { anthropic } from "@ai-sdk/anthropic";
import { components } from "./_generated/api";
export const myAgent = new Agent(components.agent, {
chat: anthropic("claude-opus-4-7"),
instructions: "…",
});
```
### Long-running / multi-step → `@convex-dev/workflow`
Anything crossing the function-time limit, needing retries on partial failure, or resumability across crashes.
### Other defaults
| Need | Component |
|---|---|
| RAG | `@convex-dev/rag` |
| Programmatic crons | `@convex-dev/crons` |
| Schema / data migrations | `@convex-dev/migrations` |
| Rate limiting | `@convex-dev/rate-limiter` |
| Counts / sums | `@convex-dev/aggregate` |
| High-throughput counters | `@convex-dev/sharded-counter` |
| Function-result caching | `@convex-dev/cache` |
| Online-user presence | `@convex-dev/presence` |
| Durable LLM streaming | `@convex-dev/persistent-text-streaming` |
| Bounded concurrency | `@convex-dev/workpool` |
External APIs (emails, payments, LLM calls) belong in `action`s. Persist via `ctx.runMutation(internal.x.y, ...)`.
### Don't add a parallel service
Convex is the backend. Before reaching for any of these, stop:
- ❌ Adding a separate database or in-memory cache. Convex queries are already reactive and cached.
- ❌ Adding a real-time service (WebSocket gateway, pub/sub). `useQuery` is reactive over WebSockets.
- ❌ Adding a separate API server. Queries/mutations/actions ARE the server.
- ❌ Adding a job queue or workflow service. Use `ctx.scheduler` + `crons.ts` + `@convex-dev/workflow`.
- ❌ Adding an object store. Use `ctx.storage`.
- ❌ Adding a vector or text search service. Use `defineTable(...).vectorIndex(...)` / `.searchIndex(...)`.
## `convex-helpers` — don't hand-roll these
`npm install convex-helpers` before writing a custom version of any of these. It's the official utility package, not a third-party dependency:
| Need | Use | Import from |
|---|---|---|
| Auth/RBAC/tenant context on every query & mutation (Convex's answer to Postgres RLS) | `customQuery` / `customMutation` — wrap once, inject `ctx.user` everywhere | `convex-helpers/server/customFunctions` |
| Follow a foreign key / join | `getOneFrom`, `getManyFrom`, `getManyVia` (many-to-many) | `convex-helpers/server/relationships` |
| Anonymous/pre-signup user tracking | `useSessionId` (client) + `SessionIdArg` (server) | `convex-helpers/react/sessions`, `convex-helpers/server/sessions` |
| Zod instead of `v.*` validators | `zCustomQuery` / `zCustomMutation` | `convex-helpers/server/zod` |
| React on data changes (fan-out notifications, computed fields) | `Triggers` | `convex-helpers/server/triggers` |
Prefer `customQuery`/`customMutation` over a hand-rolled row-level-security helper — same idea, but type-checked at compile time instead of a runtime rule engine. Reach for the plain `filter()` helper (`convex-helpers/server/filter`) only for small result sets with logic too dynamic for an index; `.withIndex(...)` is still the default.
## Runtime errors — what they mean
| Error | Cause | Fix |
|---|---|---|
| `Schema validation failed` | A row doesn't match the new schema | Make the field `v.optional()`, backfill, then tighten |
| `ReturnsValidationError` | Returned shape doesn't match `returns` validator | Map private fields out on read, or update validator |
| `ArgumentValidationError` | Client sent args that don't match validator | Restart `convex dev` and client; codegen is stale |
| `SystemTimeoutError` | Function exceeded its time limit | Common cause: many sequential mutations from a Node API route. Batch or move to scheduler |
| `Too many reads in a single function execution` | `.collect()` on a large indexed query | Paginate or move to background sweep via `@convex-dev/migrations` |
| `Too many writes in a single function execution` | Single transaction > ~8K writes | Batch via `ctx.scheduler` or `@convex-dev/workpool` |
| `OCC conflict` | Two mutations stomped on the same doc | Reduce contention; sharded counters for hot increments |
| `IndexNameReserved` | Index named `by_id`, `by_creation_time`, or starts with `_` | Rename it |
| `use node` in error | Imported a Node-only module into a default V8 file | Add `"use node";` at the top, or move to an action |
| `TypeError: Cannot read properties of null (reading 'redirect')` | Convex Auth missing env keys | `npx @convex-dev/auth --skip-git-check --web-server-url <url>` |
| App stuck "signed out" — sign-in succeeds, tokens mint, but `getAuthUserId`/`getUserIdentity` is always `null`, queries return `[]`, **no error** | `convex/auth.config.ts` was never created (no registered JWT issuer) | Create `convex/auth.config.ts` (see Auth section) and re-push |
| `nonInteractiveError` / `Cannot prompt for input` | TTY-required prompt under a non-TTY harness | `CONVEX_AGENT_MODE=anonymous` before `npx convex dev` |
## Visual quality — don't ship grey-on-grey
Agents reliably ship low-contrast, all-monospace UIs and call them done.
- **Use the design system.** If the project has shadcn/ui (the `nextjs-shadcn` / `nextjs-convexauth-shadcn` templates do), use `<Button>`, `<Card>`, `<Input>`, `<Badge>`, `<Tabs>` everywhere. Never hand-write `<div className="bg-zinc-800 …">` when a primitive fits.
- **≥4:1 contrast** on borders, dividers, labels. `border-zinc-700` on `bg-zinc-950` is too dim — go to `border-zinc-500` or lighter.
- **Saturated accents.** `bg-sky-600 text-white` for primary actions, not `bg-sky-500/10` (reads as grey).
- **Don't make everything monospace.** Reserve mono for code; use a sans for UI chrome.
- **Canvas / graph libraries need explicit dark-theme overrides.** React Flow, Cytoscape, Mermaid, vis.js, D3 — all light-mode-first by default and illegible on dark.
## How you write code
- **Write entire files.** No `// ... rest unchanged` placeholders.
- **When you rewrite an existing file, preserve every export it already had.** Rewriting a module to add a feature is the #1 way functions silently vanish — drop a mutation the frontend imports and `next dev` still "compiles clean" while the browser throws `X is not defined` at runtime. Before you finish a rewrite, diff your exports against the prior version; a removed export must be deliberate, never incidental.
- **Gate on `tsc --noEmit`, not "it compiled."** A clean Convex push and `next dev`'s loose HMR typecheck both miss whole classes of error — a dropped component, a `string` passed where a branded `Id<...>` is required, a render-only crash. These surface only in the browser overlay, never in the logs the bootstrap watchers tail. `tsc --noEmit` catches them; treat green tsc, not green HMR, as done.
- **After writing**, let `convex dev` push and report. Fix TS / schema errors in place; re-push. Don't accumulate broken state.
- **Verify the watchers fire.** Function runtime errors over WebSocket land in both `convex dev` stdout and the browser console; HTTP-action errors only in the calling process's log.
- **Use the Convex MCP server when available.** Tools like `tables`, `function-spec`, `data`, `run-once-query`, `logs`, `env list/set/get` let you introspect the live deployment rather than guess from generated types.
- **Don't ask the user a question you can derive from the schema or guidelines.** Read `convex/schema.ts` first; ask only when you genuinely cannot proceed.
## Keyless external APIs (server-side)
Convex functions call external APIs from a **server**, not a browser — so any API
that keys off the caller's IP, requires a browser origin, or bans datacenter IPs
will fail in production even though it "worked" from the client during dev. Pick
keyless, server-friendly endpoints:
- **Reverse geocoding / geocoding:** use **Nominatim** (OpenStreetMap) with a real
`User-Agent` header, ≤1 req/s, and an in-memory cache — or **Open-Meteo's**
geocoding endpoint. **Avoid `*-client` SDKs and BigDataCloud's
reverse-geocode-client** (browser-only; bans server IPs).
- **Weather:** Open-Meteo (keyless). **Transit/finance/sports:** prefer official
keyless real-time endpoints; don't assume a queryable historical dataset exists
(e.g. there is no general historical Muni on-time API) — verify before designing
around it.
- Anything requiring a key → put it in a Convex **env var** (`npx convex env set`),
never inline; read it server-side.
**Smoke-test before you hand off.** After `convex dev` is ready, run ONE realistic
end-to-end invocation of the main action you wrote (`npx convex run <module>:<action> '{…}'`)
and assert the key invariants in the result (e.g. string labels aren't `undefined`,
the external call returned data). A clean push is not proof the integration works.
## Further reading
Full canonical rules: https://convex.link/convex_rules.txt. Component catalog: https://www.convex.dev/components. Auth docs: https://docs.convex.dev/auth/convex-auth.
SHA-256: 20d4f646e54f96981163e9c6e6eb132713ef6d576e52dbf6a7660120df2d4e12