Mike Cann's avatar
Mike Cann
a month ago

Is your agent writing proper Convex code?

So, I don't know about you guys, but these days I barely write any of the code myself. My agent does most of it, and to be honest it generally feels pretty good. But how do I know that what is getting written is actually good code? You know, is it good proper Convex code?

Well, one really good way of finding that out is by layering on as many deterministic code quality tools as possible, such as ESLint, and in particular the Convex ESLint plugin, which got a big upgrade with version 5.

If you aren't familiar, ESLint basically just reads through your code and flags anything that breaks a set of rules, and the Convex plugin adds a bunch of rules that are specific to Convex. Here it is running over a little demo app that my agent made, with a bunch of deliberate mistakes in it:

0:00 / 0:00

So that's 8 problems, in code that deploys just fine.

Oh and by the way, if you watch the video version of this you'll notice I look a little bit different. I was trying out something new with that one, and the whole thing is a cartoon version of me. Sure it's not the real me, but the real me can't do this:

0:00 / 0:00

Anyways, where was I?

Guidelines vs ESLint

Now, thanks to the AI files, we already give your agent a bunch of guidelines on how to write Convex properly, and they definitely do help. But agents don't always listen, especially once the conversation gets long and your guidelines are way off the top of the context somewhere.

ESLint on the other hand runs every time your agent runs lint, and when something is wrong it tells the agent what to do instead. That does mean your agent has to actually run it, so it's worth telling it to in your AGENTS.md or CLAUDE.md.

Setting it up

Setting it up is super easy. You literally just point your agent at the Convex docs page for the ESLint plugin and it'll do it all for you. You should end up with something like this, which is the config from that demo app:

1// eslint.config.js
2import { defineConfig } from "eslint/config";
3import tseslint from "typescript-eslint";
4import convexPlugin from "@convex-dev/eslint-plugin";
5
6export default defineConfig([
7  { ignores: ["convex/_generated/**"] },
8  {
9    files: ["**/*.ts"],
10    languageOptions: {
11      parser: tseslint.parser,
12      parserOptions: {
13        projectService: true,
14        tsconfigRootDir: import.meta.dirname,
15      },
16    },
17  },
18  ...convexPlugin.configs.recommended,
19]);
20

Simple enough, right? The typescript-eslint part is what lets ESLint read your TypeScript, and it also lets the plugin look at your types, which matters for one of the fixes in a minute.

It works with the older .eslintrc.js style config too, and even Oxlint if that's your thing. By default it only looks at the files in your convex directory, so it won't go complaining about the rest of your app.

Someone in the comments on the video asked about Biome. Biome can't load ESLint plugins, so it can't run these rules by itself, but you can run them alongside it with Oxlint or ESLint on just your convex directory.

What it catches

All right, let's dig in a bit now and have a look at some of the specific things it's going to catch for us. The editor clips in this post are all from that same demo app.

First up, and this is one of the new ones in version 5, if your agent uses process.env in your Convex code this will get flagged. That's because earlier this year Convex came out with a much better typed environment variables system, so this rule makes sure you're using that instead. Keep an eye on line 7:

0:00 / 0:00

Noice! npx eslint --fix swaps it for env.OPENAI_API_KEY and adds the import up on line 1. It can only do that by itself when the variable is already declared in your convex.config.ts, as it is here. If it isn't declared you get offered the same change as a suggestion in your editor instead.

db.get and the old function syntax

Next up is db.get. There's a newer API here that your agent should be using, so if it leaves off the table name, the plugin will add it in for you. This one's on line 7 as well:

0:00 / 0:00

This is the fix that needs those types from earlier, as that's how it knows the ID belongs to tasks. In Oxlint you still get told about it, but it can't fill in the name for you.

And if your agent is writing the old style of Convex function, that's going to get fixed up as well:

0:00 / 0:00

Also really good. The real fix does leave the indentation a bit wonky tho, so let your formatter have a go at it afterwards.

Using .filter on queries

Now, this is one I used to see agents doing a lot, and to be fair it's something I have definitely done myself in the past as well 😅 It's using .filter on queries. It's fine when you know your table is tiny and there's not a lot of data to filter through, but as your table grows larger and larger it's going to have to read more and more data into the query to be able to filter it.

So the solution, as we probably know, is to use indexes. The plugin can't fix this one directly as it can't tell you exactly which index to use, but it will warn your agent so it can go and swap it for a more appropriate one. Here's the warning on line 23, and then that line getting swapped for .withIndex:

0:00 / 0:00

Args validators and duplicate indexes

Oh and if your agent forgets the args validator on a function, that's going to be an error as well. It's obviously pretty important, because you don't want people to send whatever data they like to your function:

A mutation with no args validator, underlined in red, with the message: Convex function is missing args validator but has parameter. Add appropriate args validator.A mutation with no args validator, underlined in red, with the message: Convex function is missing args validator but has parameter. Add appropriate args validator.

Another new rule in version 5 is duplicate indexes. The way this works is that if your agent adds an index on user and date when you already have one on just the user, the plugin will point out that the first one is now redundant, as the second one can already do everything that it does and more. So here by_user gets flagged, and then it gets deleted:

0:00 / 0:00

It's always a good idea to keep the number of indexes you're running low, as each one slows down your writes and takes up storage. The one catch, which the message points out, is the sort order. by_user sorts the matching rows by _creationTime and by_user_and_date sorts them by date, so if you really need the first one you can keep it and disable the rule on that line.

Crons at the top of the hour

Oh and here's a fun one. If your agent schedules a cron to run exactly at the top of the hour, it's going to get a warning. That's because loads of apps schedule their stuff to run exactly at the top of the hour, so it becomes the busiest moment on the clock, and that makes it quite hard to balance and manage Convex's traffic behind the scenes. It's a bit like everyone piling through one door at 12 o'clock while one guy strolls through the other:

0:00 / 0:00

So if your agent can just pick a slightly different minute before or after, then your app and the poor Convex infra engineers are going to be a little bit happier. In this one the 0 in "0 * * * *" gets the warning, and then it gets changed to 17:

0:00 / 0:00

If you read that message you'll see there's an even easier way, which is to use the helper and not give it a minute at all, then Convex picks one for you and spreads the runs across the hour:

1crons.hourly("cleanup completed tasks", internal.tasks.cleanup);
2

Super cool!

The one I'd turn on: require access control

Now, most of these rules are on by default, but there are a few that are off, and this is one of those. It's called require-access-control, and it makes sure that every public function your agent writes checks who is actually going to be calling it.

You turn it on by adding this after the recommended config:

1{
2  files: ["**/convex/**/*.ts"],
3  rules: { "@convex-dev/require-access-control": "error" },
4},
5

In this clip that rule goes in first, then we flip over to users.ts, where deleteUser goes red with a message that starts "Anyone on the internet can call this Convex function". Adding a call to requireUser makes it go away:

0:00 / 0:00

So basically it's going to enforce that inside your query, mutation or action there's something like requireUser. Otherwise it really should just be an internalMutation, internalQuery or internalAction instead, as those can't be called from the outside.

The way it works is a little bit simplistic. It just looks for function names starting with require, assert, check, ensure, can or has, so requireUser or hasRole, but you can change that pattern if you're using something else. It can't tell if it's the right check for that function either, and it doesn't look at HTTP actions or custom functions.

I think it's off by default because it would probably trigger too many warnings in existing apps, so it would be too much of a breaking change. But I would say you probably should enable this, as it's worth having more protection than less.

Why it works so well with agents

So why I really like this with agents is that the messages the plugin gives them don't just say that something is wrong, they generally say what to do about it too. Here's what your agent gets for that duplicate index when it runs npx eslint .:

1convex/schema.ts
2  14:6  error  Index "by_user" indexes the start of index "by_user_and_date" (["userId", "dueDate"]), which answers the same queries. Every index slows down writes and adds storage, so delete "by_user" and query "by_user_and_date" instead. Keep both only if you need the different sort order: "by_user" sorts matching rows by `_creationTime`, "by_user_and_date" sorts them by "dueDate". To keep it, add `// eslint-disable-next-line @convex-dev/no-duplicate-indexes`  @convex-dev/no-duplicate-indexes
3

That's pretty much everything it needs to go and sort it out by itself. And generally these fixes will happen before you're even aware of them, which is very nice if you ask me.

Conclusion

So just to recap: guidelines definitely help but agents don't always listen, and lint flags the mistake every time it runs. Point your agent at the docs to get it set up, and I'd turn on require-access-control while you're there.

I really like these ESLint plugins. Every time we see agents making the same Convex mistakes over and over, I think that's a pretty good candidate for a new rule, and hopefully this becomes a really nice way to make sure your agents are writing, you know, proper Convex code.

A massive thanks to Nicolas, who looks after the plugin.

Until next time, cheerio! 👋

All gas, no breakages

Convex is the reactive backend platform that keeps up with you and your agents. Database, functions, workflow, sync, search, file storage, and more. All TypeScript, zero glue.

Get started