# Convex Expert

> 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.

- Skill: `get-convex/convex-expert-3` (Agent Skill)
- Install (CLI): `npx skillmds@latest add get-convex/convex-expert-3`
- Raw SKILL.md: https://api.skillmd.com/api/skills/get-convex/convex-expert-3/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: Apache-2.0
- Author: get-convex (https://skillmd.com/u/get-convex)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/get-convex/convex-expert-3

---


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`.
- **There is no `.range(...)` method on a `withIndex` callback.** The index-range builder only has `eq`/`gt`/`gte`/`lt`/`lte`, chained directly on the callback param — e.g. `q.eq("acknowledged", false).lte("alertedAt", Date.now())`. `.withIndex("by_x", (q) => q.eq(...).range((r) => ...))` is a hallucinated API (verified against the `convex` package's `IndexRangeBuilder` type) and fails to type-check.
- **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.
- **Bound every numeric arg that becomes a delta.** A `value`/`delta`/`amount` field typed bare `v.number()` and then added onto an existing balance, score, or quantity (`patch(id, { score: existing.score + args.value })`) lets a client send an arbitrary or negative number and corrupt that field — this is a common real defect (a vote endpoint accepting `v.number()` let a client inflate any score arbitrarily; an inventory endpoint let `NaN`/`Infinity` quantities through unguarded arithmetic). If the value is one of a fixed set (a vote is `+1`/`-1`), use `v.union(v.literal(1), v.literal(-1))`. If it's a free magnitude, validate explicitly in the handler — reject `NaN`, `Infinity`, and out-of-range values before using it.
- **A retried mutation should be a no-op, not a toggle.** An HTTP-layer retry of an identical request (same user, same target, same value) is the normal shape of a network retry — if the mutation's logic is "if a matching row exists, delete it, else create it" (a naive toggle), the retry silently undoes the first call's effect. Prefer `requestId`-keyed idempotency (check for an existing row with the same client-supplied request id and short-circuit) over toggle-on-presence logic for anything reachable from an HTTP endpoint.
- **`"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.
- **Never name exports with JS reserved words.** `export const delete = mutation(...)` fails to build (`Expected identifier but found "delete"`) — `delete`, `new`, `class`, `function`, `return`, `import`, `default`, `typeof`, `void`, etc. can't be export names. Use a synonym: `remove`, `destroy`, `create`.
- **Node builtins need a `"use node"` action file — prefer Web Crypto.** `import crypto from "crypto"` (or `fs`/`path`/`http`/`child_process`/`os`, with or without the `node:` prefix) in any non-`"use node"` file — including `http.ts` route handlers, not just queries/mutations — fails to bundle: Convex's default runtime is a V8 isolate with no Node builtins. Either move that code into an action file starting with `"use node";`, or, for crypto specifically, use the ambient Web Crypto API (`crypto.subtle`, `crypto.randomUUID()`) which needs no import and runs in the default runtime.
- **Convex functions only run from the `convex/` directory.** Never write `schema.ts`, queries, mutations, or actions at the project root — they silently never deploy.
- **`httpRouter` has no Express-style `:param` routes.** `http.route({ path: "/api/users/:userId", ... })` only ever matches that literal string — Convex's router is exact-match or `pathPrefix`, never a dynamic segment. Use `pathPrefix: "/api/users/"` and parse the trailing segment yourself: `new URL(request.url).pathname.split("/").pop()`. A model that writes `:id`/`:param` routes ships an app where every parameterized endpoint is dead code — this is one of the most common defects in generated Convex backends.
- **Every `http.route({...})` `handler:` must be wrapped in `httpAction(...)`** (imported from `./_generated/server`) — a bare `async (ctx, request) => {...}` is not a valid HTTP action even though it type-checks.
- **Wrap every `v.id(...)`-cast HTTP path/query param in a `try`/`catch`.** Casting a raw URL segment `as any` into a `v.id("table")` arg (`ctx.runQuery(api.foo.bar, { id: rawParam as any })`) throws an uncaught `ArgumentValidationError` on any malformed or wrong-table ID, which surfaces to the caller as an opaque 500 instead of a clean 400/404. Catch it and return a real error response.
- **A mutating HTTP route with a real-world side effect (payment, ledger post, inventory decrement, order placement) needs an idempotency key.** A client or network retry after a dropped response re-runs the handler and double-applies the effect — accept a client-supplied `requestId`/`idempotencyKey`, check for an existing row with that key first, and short-circuit if found (return the prior result rather than repeating the write).
- **`ctx.runQuery`/`ctx.runMutation`/`ctx.runAction` take a codegen'd function reference (`api.foo.bar` / `internal.foo.bar`), never the raw imported function.** `import * as queries from "./queries"; ctx.runQuery(queries.getX, ...)` compiles (both shapes are structurally callable) but fails at runtime — it needs `import { api } from "./_generated/api"; ctx.runQuery(api.queries.getX, ...)`.
- **Never call `ctx.runQuery`/`ctx.runMutation` from inside a `query` handler.** Queries must be pure reads within the same transaction; call the other query's logic directly (extract a shared helper) or move the composition into an action.
- **`ctx.runQuery`/`ctx.runMutation`/`ctx.runAction` never take a string.** `ctx.runMutation("users:getOrCreateUser", {...})` is not a `FunctionReference` — it type-checks as a string but fails at runtime/deploy. Always `import { api, internal } from "./_generated/api"` and pass `api.users.getOrCreateUser` (or `internal.users.getOrCreateUser`).
- **There is no `.count()` or `.skip()` on a Convex query builder.** `ctx.db.query(...).withIndex(...).count()` and `.order("desc").skip(n).take(m)` are both hallucinated APIs — the builder only has `collect`/`take`/`first`/`unique`/`paginate`. For a count, `.collect()` and read `.length` (bounded tables) or maintain a running counter field on the parent document (unbounded ones). For an offset page, use cursor-based `.paginate(paginationOpts)`, not `.skip(n)`.
- **`.take(n)` then filter-in-JS silently drops correct rows, not just perf.** `ctx.db.query(...).take(1000)` followed by an in-memory `.filter(...)`/sort assumes the answer is inside the first `n` rows fetched in *index/creation order* — once the table exceeds `n`, matching rows beyond the cutoff are silently missing from the result (not an error, just quietly wrong: "newest N" becomes "oldest N", a dedupe check against the first N stops catching duplicates, a tag/status filter returns an empty page even though matches exist further down). If you need "all rows matching X," query by an index on X, not by taking N unfiltered rows and filtering client-side afterward.
- **Every client-controlled numeric/date-range argument needs an upper bound, not just a lower one.** An index range built with only `q.gte(...)` (or an HTTP query-string `limit`/`from`/`to` parsed with a bare `Number(...)`) is unbounded on the open side — a "future reminders" query with no `lte` upper bound reads every row forever forward; an HTTP `limit` param with no `Math.min`/integer check lets `NaN`, `Infinity`, or a negative number reach `.take(...)` and crash or return nonsense. Validate both ends: reject `NaN`/non-finite/negative, clamp to a sane max, and give every index range both a floor and a ceiling.

## 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. Push it to a deployment. Prefer the project's existing one; otherwise `npx convex dev --once` when `npx convex whoami` succeeds, and `CONVEX_AGENT_MODE=anonymous npx convex dev --once` ONLY when it does not — forcing anonymous on a signed-in user rebinds `.env.local` and costs them their persistent, publishable cloud deployment.

**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`.
- **Table names can't start with `_` either.** `_migrations: defineTable(...)` errors at push as `TableNameReserved` — same underscore-prefix rule as index names, just one level up. Drop the leading underscore (`migrations: defineTable(...)`).

### 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.
- **Check every mutation independently — don't generalize an auth check across a file.** A `ctx.auth`/ownership check present in one mutation does not mean a sibling mutation in the same file is also covered; each public `mutation`/`query` that trusts a client-supplied `userId`/`authorId`/`ownerId` arg without an independent `ctx.auth.getUserIdentity()` call (or a comparison against it) is a separate authz bug, even next to a function that does it right. This is a common false-negative: a model that sees one correct check in a file assumes the pattern applies everywhere and skips it on the next mutation down.
- **An "operate on this ID" mutation (`cancel`, `edit`, `setStatus`, `accept`) needs an ownership check on the *document*, not just an identity check on the *caller*.** `ctx.auth.getUserIdentity()` proves who's calling; it doesn't prove they're allowed to touch the specific row an `_id` argument points at. `cancelAppointment(appointmentId)`, `editAnswer(answerId, body)`, `setOrderStatus(orderId, status)` must load the doc and compare its owner/author field against the authenticated identity before mutating — otherwise any logged-in user can act on any other user's row just by supplying its id (which is often guessable or visible in a list response).
- **A query returning another party's private data (PII, order history, revenue, audit logs) by an argument the client supplies is a leak, even if it "looks read-only."** `getUserOrders(userId)`, `getSellerDashboard(shopId)`, `listAuditLogForStaff(staffId)` are exploitable the same way an unauthenticated mutation is: derive the subject from `ctx.auth`, not from the argument, or verify the argument matches the authenticated identity before returning anything.
- **MANDATORY FIRST STEP — check the auth foundation exists before injecting any `ctx.auth` enforcement.** `requireIdentity`/`requireOwner` (below) only work when the app actually has auth wired up: (1) is there an `auth.config.ts` with a provider? (2) is there a `users`/identities table keyed to the auth subject (`tokenIdentifier`/`identity.subject`)? If EITHER is missing, do **not** add `requireIdentity`/`requireOwner` — on a foundationless app `ctx.auth.getUserIdentity()` always returns `null`, so the "fix" either 401s every caller (non-functional) or gets miscompared against some other client-supplied field like an email string (mismatched) — this is a *new* authz defect, not a repair, and a reviewer will correctly flag it as one. Instead, on a foundationless app: (a) for privileged/admin operations, convert the public `query`/`mutation` to `internalQuery`/`internalMutation` — this removes public reachability entirely, which is safe and requires no auth foundation; (b) tell the user: "this app has no auth foundation; run `/add auth` or the auth setup first, then re-run convex-authz to add per-user ownership checks." Only once both the provider config and the subject-keyed users table exist should you proceed to inject `requireIdentity`/`requireOwner`.
- **Copy this pattern instead of re-deriving it per function.** The rules above (identity-from-arg, missing ownership check, PII-by-argument) are the single largest real-defect cluster measured against generated Convex backends — 44 of 214 confirmed defects in one 30-app corpus. Two small helpers close them at once — **but only once the foundation check above passes**:

  ```ts
  // convex/model/auth.ts
  import { QueryCtx, MutationCtx } from "../_generated/server";

  /** Throws if unauthenticated. Never trust a client-supplied userId instead. */
  export async function requireIdentity(ctx: QueryCtx | MutationCtx) {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("401: not signed in");
    return identity; // identity.subject is the stable per-user id
  }

  /** Loads `doc` by id and throws unless its owner field matches the caller. */
  export async function requireOwner<T extends { ownerId: string }>(
    ctx: QueryCtx | MutationCtx,
    doc: T | null,
  ): Promise<T> {
    if (!doc) throw new Error("404: not found");
    const identity = await requireIdentity(ctx);
    if (doc.ownerId !== identity.subject) throw new Error("403: forbidden");
    return doc;
  }
  ```

  **Match the comparison to what the schema actually stores in the owner field.** The version above assumes `ownerId` holds the raw auth subject. Most schemas instead store an `Id<"users">` (`ownerId: v.id("users")`, `senderId: v.id("users")`, a `participantIds` array) — there, comparing against `identity.subject` NEVER matches and silently breaks enforcement (every legitimate owner gets 403, or the check gets loosened until it always passes). For those schemas, add a `requireUser(ctx)` step that resolves the caller's own `users` row through the subject-keyed index (`by_token` / `tokenIdentifier`) and compare `doc.ownerId !== user._id` instead; for membership-array containers check `participantIds.includes(user._id)`.

  ```ts
  // convex/appointments.ts — the three rules applied together
  export const cancel = mutation({
    args: { appointmentId: v.id("appointments") }, // NOT `cancelledBy`/`actorId` from the client
    returns: v.null(),
    handler: async (ctx, args) => {
      const appointment = await ctx.db.get(args.appointmentId);
      await requireOwner(ctx, appointment); // ownership check on the DOCUMENT, not just "is someone logged in"
      await ctx.db.patch(args.appointmentId, { status: "cancelled" });
      return null;
    },
  });
  ```

  The four hard rules this codifies, every time:
  1. **Identity comes from `ctx.auth`, never from an argument.** A `userId`/`actorId`/`ownerId`/`authorId`/`accountId` field on `args` is a standing invitation to impersonate — if the function needs to know who's calling, call `requireIdentity(ctx)`, don't accept it as a parameter (an internal/admin function that must operate on an arbitrary user is the one legitimate exception — keep it `internalMutation`/`internalQuery`, never public).
  2. **Every read or mutate keyed by an `_id` argument verifies ownership server-side before touching the row.** Loading the doc and checking `ctx.auth` proves someone is logged in; it doesn't prove they own *this* row. `requireOwner` (or the same comparison inlined) closes that gap for `cancel`/`edit`/`setStatus`/`accept`-shaped mutations and for `get*`/`list*`-shaped queries alike.
  3. **Never expose a public query that returns PII or financial fields (email, revenue, order/audit history) gated only by a client-supplied id.** Scope every such query through `requireIdentity`/`requireOwner` (or an explicit staff/role check) before it touches rows outside the caller's own scope.
  4. **A `v.id(...)` arg used as a foreign key on a write needs the *parent's* ownership checked, not just the caller's identity.** `createTask({ projectId })`, `addCard({ boardId })`, `createInvoice({ accountId })` must load the referenced project/board/account and `requireOwner` it (or verify membership) before inserting the child row — creating into someone else's container is the same defect as mutating their row, and it survives an identity-from-arg fix unless checked separately. After fixing rules 1–3, re-audit every *remaining* `v.id(...)` arg in every public mutation for this.
- 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:

### Agentic apps (threads / tools / RAG) → `@convex-dev/agent`

When an app needs multiple threads, tool-calls, RAG, or durable multi-turn history, reach for `@convex-dev/agent` instead of hand-rolling it.

<!-- AGENT-STREAMING-INTERIM: revert when get-convex/agent#295 ships `listUIMessagesWithStreams`. The component's streaming query does not type-check from scratch today (TS2719); restore "any chat/LLM → @convex-dev/agent" once #295 releases. -->
For a **simple streaming chat** (one thread, token-by-token), build it directly: a `messages` table plus an action that streams tokens in, read by a reactive query. That is currently more reliable than the component's streaming wiring.

```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 |
| `TableNameReserved` | Table name starts with `_` (e.g. `_migrations`) | Drop the leading underscore |
| `Expected identifier but found "delete"` (or another keyword) | `export const delete = ...` — export name is a JS reserved word | Rename to a synonym (`remove`, `destroy`) |
| `use node` in error | Imported a Node-only module (e.g. `crypto`, `fs`) into a default V8 file — including `http.ts` route handlers | Add `"use node";` at the top and move to an action, or use Web Crypto (`crypto.subtle`) instead of `import`ing `crypto` |
| `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 team prompt under a non-TTY harness | Set `CONVEX_TEAM=<slug>` (keeps their cloud deployment). `CONVEX_AGENT_MODE=anonymous` only when `npx convex whoami` fails |

## 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.

