AI Agents (and humans) do better with good abstractions

AI Agents (and humans) do better with good abstractions

At Convex, we've built an AI agent, Chef, that can build a collaborative text editor like Notion or a complex chat app like Slack with full-text search in a single prompt. How is this possible?! Better AI models? Claude 4 is great, but not good enough to do that on its own. We don't have some secret sauce in our agent.

Convex has good abstractions

We've been able to build such a powerful agent because Convex has good abstractions. What does that mean? Convex's development paradigm is easy for humans and AI agents to understand. We repeatedly hear from customers that our developer experience is unparalleled, that they can build faster because they spend much less time debugging and reasoning about complex code. Convex's transactional queries and mutations allow them to express exactly what data they want to read and write in an easy-to-understand way. Reading and writing from the database is as simple as writing familiar TypeScript code. Here's an example of a Convex query to list messages and a Convex mutation to send a message in a chat app with channels:

1// convex/messages.ts
2
3export const list = query({
4  args: { channelId: v.id("channels") },
5  handler: async (ctx, args) => {
6    const userId = await getAuthUserId(ctx);
7    if (!userId) {
8      throw new Error("Not authenticated");
9    }
10
11    const messages = await ctx.db
12      .query("messages")
13      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
14      .order("asc")
15      .collect();
16
17	return messages
18  },
19});
20
21export const send = mutation({
22  args: {
23    channelId: v.id("channels"),
24    content: v.string(),
25  },
26  handler: async (ctx, args) => {
27    const userId = await getAuthUserId(ctx);
28    if (!userId) {
29      throw new Error("Not authenticated");
30    }
31
32    return await ctx.db.insert("messages", {
33      channelId: args.channelId,
34      authorId: userId,
35      content: args.content.trim(),
36    });
37  },
38});
39

These functions hook up to your React app via useQuery and useMutation hooks:

1import { useMutation, useQuery } from "convex/react";
2import { api } from "../convex/_generated/api";
3
4export function Chat({ channelId }): { channelId: string } {
5  const listMessages = useQuery(api.messages.list, { channelId });
6  const sendMessage = useMutation(api.messages.sendMessage);
7  const handleSubmit = () => {
8    sendMessage({ channelId, content });
9  };
10  // pass `handleSubmit` to a text input form
11  // display messages from `listMessages`
12  // ...
13}
14

Queries automatically update when the data they are subscribed to changes, and mutations execute atomically. Developers don’t need to write code that polls the server for changes or manages inconsistent state. These guarantees drop developers and AI agents in the “pit of success” to write full-stack apps that just work.

Built-in features

Chef can build powerful full-stack apps from scratch because Convex has some built-in features Convex has built-in features that are usually separate services, like full-text search and file uploads. Chef builds a Slack clone with full-text search and file uploads for profile pictures without having to set up Elasticsearch or configure S3.

Here's an example of a query Chef wrote in response to the Slack clone prompt that uses full-text search and auth, optionally filtering on a channel.

1export const search = query({
2  args: {
3    query: v.string(),
4    channelId: v.optional(v.id("channels")),
5  },
6  handler: async (ctx, args) => {
7    const userId = await getAuthUserId(ctx);
8    if (!userId) {
9      throw new Error("Not authenticated");
10    }
11    return ctx.db
12      .query("messages")
13      .withSearchIndex("search_content", (q) => {
14        let search = q.search("content", args.query);
15        if (args.channelId) {
16          search = search.eq("channelId", args.channelId);
17        }
18        return search;
19      })
20      .take(50);
21	}
22});
23

Convex Components

Convex isn't just a full-featured application platform or backend-as-a-service. Convex is extensible. We launched a components system last fall that enables any developer to write an npm package that is basically a drop-in microservice that can be plugged into any Convex app. A Convex component is a separate namespace in your deployment with its own functions and tables. These functions can be called from Convex functions in your root namespace, and the functions run in isolation in their own tables but within the same transaction as the parent function in the root namespace. This unlocks powerful guarantees - you can have completely isolated, modular, reusable functions and data executing transactionally within your application code. In a more traditional architecture, you might use a library (that doesn't have access to its own data, and hence not true modularity) or a microservice (which requires communication over a network and no transactional guarantees).

When you prompt Chef to build a collaborative text editor, Chef leverages the Collaborative Text Editor Convex component. Instead of writing thousands of lines of code to properly sync the content of the document across multiple clients, Chef defines a couple methods for checking authentication and then calls the component method that handles the rest. Here's some code Chef generated in one shot for the Notion clone prompt:

1import { components } from './_generated/api';
2import { ProsemirrorSync } from '@convex-dev/prosemirror-sync';
3import { getAuthUserId } from "@convex-dev/auth/server";
4import { DataModel } from "./_generated/dataModel";
5import { GenericQueryCtx, GenericMutationCtx } from 'convex/server';
6
7const prosemirrorSync = new ProsemirrorSync(components.prosemirrorSync);
8
9async function checkPermissions(ctx: GenericQueryCtx<DataModel> | GenericMutationCtx<DataModel>, documentId: string) {
10  const userId = await getAuthUserId(ctx);
11  if (!userId) {
12    throw new Error("Must be logged in to access documents");
13  }
14
15  const document = await ctx.db.get(documentId as any);
16  if (!document) {
17    throw new Error("Document not found");
18  }
19
20  // Check if this is a document from our documents table
21  if ('isPublic' in document && 'createdBy' in document) {
22    // Allow access if document is public or user is the creator
23    if (!document.isPublic && document.createdBy !== userId) {
24      throw new Error("Unauthorized to access this document");
25    }
26  }
27}
28
29async function checkWritePermissions(ctx: GenericMutationCtx<DataModel>, documentId: string) {
30  const userId = await getAuthUserId(ctx);
31  if (!userId) {
32    throw new Error("Must be logged in to edit documents");
33  }
34
35  const document = await ctx.db.get(documentId as any);
36  if (!document) {
37    throw new Error("Document not found");
38  }
39
40  // Check if this is a document from our documents table
41  if ('isPublic' in document && 'createdBy' in document) {
42    // Only allow editing if document is public or user is the creator
43    if (!document.isPublic && document.createdBy !== userId) {
44      throw new Error("Unauthorized to edit this document");
45    }
46  }
47}
48
49export const { getSnapshot, submitSnapshot, latestVersion, getSteps, submitSteps } = prosemirrorSync.syncApi<DataModel>({
50  checkRead: checkPermissions,
51  checkWrite: checkWritePermissions,
52  onSnapshot: async (ctx, id, snapshot, version) => {
53    // Update the document's lastModified timestamp when content changes
54    const document = await ctx.db.get(id as any);
55    if (document) {
56      await ctx.db.patch(id as any, {
57        lastModified: Date.now(),
58      });
59    }
60  },
61});
62
63

The front-end code for plugging in the collaborative text editor is dead simple:

1import { useBlockNoteSync } from '@convex-dev/prosemirror-sync/blocknote';
2import '@blocknote/core/fonts/inter.css';
3import { BlockNoteView } from '@blocknote/mantine';
4import '@blocknote/mantine/style.css';
5import { api } from '../convex/_generated/api';
6
7function CollaborativeEditor({ id }: { id: string }) {
8  const sync = useBlockNoteSync(api.example, id);
9  return sync.isLoading ? (
10    <p>Loading...</p>
11  ) : sync.editor ? (
12    <BlockNoteView editor={sync.editor} />
13  ) : (
14    <button onClick={() => sync.create({ type: 'doc', content: [] })}>Create document</button>
15  );
16}
17

Convex has built components for payments with Polar, geospatial search indexes, AI agents, and many more that help Convex apps scale that are not yet supported in Chef. Check them out here.

We're excited about the potential of the component ecosystem to superpower Convex developers and AI agents. There's no need to reinvent the wheel when you can use a component, and we built components on top of the platform instead of as built-in features of Convex so that developers can write their own components. Instead of reimplementing the same features for each project, write a component and drop it in instead!

Chef might seem impressive because it can build full-stack, feature-full apps from scratch, but it's not special. Any AI agent can do the same. We built Convex with good abstractions to be the best reactive database for developers, and it turns out LLMs appreciate them too. If you’re building an AI coding platform, learn more about how to use Convex to back your generated apps here. And if you’re curious (or skeptical), try out Chef here!

Build in minutes, scale forever.

Convex is the backend platform with everything you need to build your full-stack AI project. Cloud functions, a database, file storage, scheduling, workflow, vector search, and realtime updates fit together seamlessly.

Get started