Convex logo

Skill

convex-expert

develop Convex backend applications

Published by Convex Updated Jul 18
Covers Backend Convex TypeScript Database

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.

SKILL.md

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:
    SymbolImport 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 actions. 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. 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

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

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

LimitValue
Reads per function~16,000 documents
Writes per function~8,000 documents
Single document1 MiB
Total payload8 MiB
Query CPU~1 second
Action runtime10 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:
    // 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).
    // 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:
    // 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.

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.

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

NeedComponent
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 actions. 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:

NeedUseImport from
Auth/RBAC/tenant context on every query & mutation (Convex's answer to Postgres RLS)customQuery / customMutation — wrap once, inject ctx.user everywhereconvex-helpers/server/customFunctions
Follow a foreign key / joingetOneFrom, getManyFrom, getManyVia (many-to-many)convex-helpers/server/relationships
Anonymous/pre-signup user trackinguseSessionId (client) + SessionIdArg (server)convex-helpers/react/sessions, convex-helpers/server/sessions
Zod instead of v.* validatorszCustomQuery / zCustomMutationconvex-helpers/server/zod
React on data changes (fan-out notifications, computed fields)Triggersconvex-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

ErrorCauseFix
Schema validation failedA row doesn't match the new schemaMake the field v.optional(), backfill, then tighten
ReturnsValidationErrorReturned shape doesn't match returns validatorMap private fields out on read, or update validator
ArgumentValidationErrorClient sent args that don't match validatorRestart convex dev and client; codegen is stale
SystemTimeoutErrorFunction exceeded its time limitCommon 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 queryPaginate or move to background sweep via @convex-dev/migrations
Too many writes in a single function executionSingle transaction > ~8K writesBatch via ctx.scheduler or @convex-dev/workpool
OCC conflictTwo mutations stomped on the same docReduce contention; sharded counters for hot increments
IndexNameReservedIndex named by_id, by_creation_time, or starts with _Rename it
TableNameReservedTable 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 wordRename to a synonym (remove, destroy)
use node in errorImported a Node-only module (e.g. crypto, fs) into a default V8 file — including http.ts route handlersAdd "use node"; at the top and move to an action, or use Web Crypto (crypto.subtle) instead of importing crypto
TypeError: Cannot read properties of null (reading 'redirect')Convex Auth missing env keysnpx @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 errorconvex/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 inputTTY-required prompt under a non-TTY harnessCONVEX_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.

© 2026 YourAI.tools. Every skill from an identity-verified publisher.

Independent catalog. Not affiliated with, endorsed by, or sponsored by Anthropic or any listed publisher. All trademarks belong to their respective owners.