| name | convex-agents |
| description | Building AI agents and assistants with Convex. Use when implementing chat interfaces, AI assistants, tool-calling agents, RAG (retrieval-augmented generation), conversation threads, or integrating LLMs like OpenAI/Anthropic. Use when this capability is needed. |
| metadata | {"author":"aaronvanston"} |
Convex AI Agents
Basic Chat Schema
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
threads: defineTable({
userId: v.string(),
title: v.optional(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
}).index("by_user", ["userId"]),
messages: defineTable({
threadId: v.id("threads"),
role: v.union(v.literal("user"), v.literal("assistant"), v.literal("system")),
content: v.string(),
createdAt: v.number(),
}).index("by_thread", ["threadId"]),
});
Thread Management
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { ConvexError } from "convex/values";
export const create = mutation({
args: {},
returns: v.id("threads"),
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError({ code: "UNAUTHENTICATED", message: "Not logged in" });
}
return await ctx.db.insert("threads", {
userId: identity.subject,
createdAt: Date.now(),
updatedAt: Date.now(),
});
},
});
export const list = query({
args: {},
returns: v.array(v.({
: v.(),
: v.(v.()),
: v.(),
})),
: (ctx) => {
identity = ctx..();
(!identity) [];
ctx.
.()
.(, q.(, identity.))
.()
.();
},
});
getMessages = ({
: { : v.() },
: v.(v.({
: v.(),
: v.(v.(), v.(), v.()),
: v.(),
: v.(),
})),
: (ctx, args) => {
ctx.
.()
.(, q.(, args.))
.()
.();
},
});
AI Integration with Actions
"use node";
import { internalAction, internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export const chat = internalAction({
args: {
threadId: v.id("threads"),
userMessage: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.runMutation(internal.ai.saveMessage, {
threadId: args.threadId,
role: "user",
content: args.userMessage,
});
const messages = await ctx.runQuery(internal.ai.getHistory, {
: args.,
});
response = openai...({
: ,
: messages.( ({
: m.,
: m.,
})),
});
assistantMessage = response.[]?.?. ?? ;
ctx.(internal.., {
: args.,
: ,
: assistantMessage,
});
;
},
});
saveMessage = ({
: {
: v.(),
: v.(v.(), v.(), v.()),
: v.(),
},
: v.(),
: (ctx, args) => {
ctx..(args., { : .() });
ctx..(, {
: args.,
: args.,
: args.,
: .(),
});
},
});
getHistory = ({
: { : v.() },
: v.(v.({
: v.(v.(), v.(), v.()),
: v.(),
})),
: (ctx, args) => {
messages = ctx.
.()
.(, q.(, args.))
.()
.();
messages.( ({ : m., : m. }));
},
});
Streaming Responses
"use node";
export const streamChat = internalAction({
args: {
threadId: v.id("threads"),
userMessage: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.runMutation(internal.ai.saveMessage, {
threadId: args.threadId,
role: "user",
content: args.userMessage,
});
const messageId = await ctx.runMutation(internal.ai.saveMessage, {
threadId: args.threadId,
role: "assistant",
content: "",
});
const messages = await ctx.runQuery(internal.ai.getHistory, {
threadId: args.threadId,
});
const stream = await openai...({
: ,
: messages.(, -).( ({
: m.,
: m.,
})),
: ,
});
fullContent = ;
lastUpdate = .();
( chunk stream) {
content = chunk.[]?.?. ?? ;
fullContent += content;
(.() - lastUpdate > ) {
ctx.(internal.., {
messageId,
: fullContent,
});
lastUpdate = .();
}
}
ctx.(internal.., {
messageId,
: fullContent,
});
;
},
});
updateMessage = ({
: { : v.(), : v.() },
: v.(),
: (ctx, args) => {
ctx..(args., { : args. });
;
},
});
Tool Calling / Function Calling
"use node";
const tools = [
{
type: "function" as const,
function: {
name: "search_documents",
description: "Search the knowledge base for relevant documents",
parameters: {
type: "object",
properties: {
query: { type: "string", description: "Search query" },
},
required: ["query"],
},
},
},
{
type: "function" as const,
function: {
name: "create_task",
description: "Create a new task for the user",
parameters: {
type: "object",
properties: {
title: { type: "string", description: "Task title" },
dueDate: { type: "string", description: "Due date in ISO format" },
},
required: ["title"],
},
},
},
];
export const chatWithTools = internalAction({
: { : v.(), : v.() },
: v.(),
: (ctx, args) => {
response = openai...({
: ,
messages,
tools,
});
choice = response.[];
(choice?. === ) {
toolCalls = choice.. ?? [];
( toolCall toolCalls) {
{ name, : argsJson } = toolCall.;
toolArgs = .(argsJson);
: ;
(name) {
:
result = ctx.(internal.., {
: toolArgs.,
});
;
:
ctx.(internal.., {
: toolArgs.,
: toolArgs.,
});
result = ;
;
:
result = ;
}
}
}
;
},
});
RAG (Retrieval-Augmented Generation)
Vector Search Schema
documents: defineTable({
content: v.string(),
embedding: v.array(v.float64()),
metadata: v.object({
source: v.string(),
title: v.optional(v.string()),
}),
}).vectorIndex("by_embedding", {
vectorField: "embedding",
dimensions: 1536,
}),
Embedding and Search
"use node";
import { internalAction, internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";
import OpenAI from "openai";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export const embed = internalAction({
args: { text: v.string() },
returns: v.array(v.float64()),
handler: async (ctx, args) => {
const response = await openai.embeddings.create({
model: "text-embedding-ada-002",
input: args.text,
});
return response.data[0].embedding;
},
});
export const indexDocument = internalAction({
args: { content: v.string(), source: v.string(), title: v.optional(v.()) },
: v.(),
: (ctx, args) => {
embedding = ctx.(internal.., {
: args.,
});
ctx.(internal.., {
: args.,
embedding,
: { : args., : args. },
});
},
});
saveDocument = ({
: {
: v.(),
: v.(v.()),
: v.({ : v.(), : v.(v.()) }),
},
: v.(),
: (ctx, args) => {
ctx..(, args);
},
});
search = ({
: { : v.(), : v.(v.()) },
: v.(v.({
: v.(),
: v.(),
})),
: (ctx, args) => {
queryEmbedding = ctx.(internal.., {
: args.,
});
results = ctx.(internal.., {
: queryEmbedding,
: args. ?? ,
});
results;
},
});
vectorSearch = ({
: { : v.(v.()), : v.() },
: v.(v.({ : v.(), : v.() })),
: (ctx, args) => {
results = ctx.
.()
.(,
q.(args.).(args.)
);
results.( ({
: r.,
: r.,
}));
},
});
RAG Chat
export const ragChat = internalAction({
args: { threadId: v.id("threads"), userMessage: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const relevantDocs = await ctx.runAction(internal.search.search, {
query: args.userMessage,
limit: 3,
});
const context = relevantDocs.map((d) => d.content).join("\n\n");
const systemMessage = `You are a helpful assistant. Use the following context to answer questions:
${context}
If the context doesn't contain relevant information, say so.`;
},
});
Common Pitfalls
- API keys in client - Always use actions with
"use node" for API calls
- Long conversations - Implement context windowing or summarization
- Missing error handling - Handle API rate limits and failures
- No streaming fallback - Have non-streaming backup for reliability
- Unbounded context - Limit message history sent to LLM
References
Converted and distributed by TomeVault — claim your Tome and manage your conversions.