npx skills add ...
npx skills add waynesutton/builder-skills --skill convex-agents
Builds AI agents on the Convex agent component: threads, messages, tools that call queries and mutations, streaming, RAG with vector search, and workflows for multi step jobs. Use when adding a chat assistant, tool calling agent, or retrieval feature to a Convex app.
npx skills add waynesutton/builder-skills --skill convex-agents
Produces a chat or tool calling agent backed by @convex-dev/agent, with thread history stored in Convex and a reactive message list for the UI. The one rule: every LLM call runs inside an action. Mutations save the prompt and schedule the action; they never call a model.
Run npx convex dev once so components.agent is generated before defining an agent.
name tags each saved message with the agent that wrote it. Everything except name can be overridden per call.
Save the user prompt in a mutation, then schedule an internal action that generates the reply. Clients subscribed to the thread see the new message without the action returning anything.
Thread ids are strings, not v.id(...), since the table lives inside the component.
listUIMessages merges tool calls and the assistant text that follows them into one UIMessage, which keeps rendering simple.
Tools are defined with createTool and get a ctx that includes runQuery, runMutation, userId, and threadId. Annotate the handler return type to avoid circular type errors.
Pass it to the agent with tools: { searchOrders } in the constructor or at the call site. For tool error handling, runtime tools with closures, and delta streaming to the client, open references/tools-and-streaming.md.
For embedding documents, searching them with @convex-dev/rag or a hand rolled vector index, injecting results into the prompt, and running several LLM steps as a durable @convex-dev/workflow job, open references/rag-and-workflows.md.
| Mistake | Why it breaks | Do instead |
|---|---|---|
Calling generateText in a mutation | Mutations cannot make network calls and must be deterministic | Save the prompt with saveMessage, schedule an internalAction |
v.id("threads") for thread ids | The threads table lives in the component, so ids are strings outside it | Use v.string() |
Skipping npx convex dev after app.use(agent) | components.agent is not generated, so types fail | Run dev once before writing agent code |
| Returning the reply text from the action to the client | Loses the reply if the client disconnects, no reactivity | Let clients read listUIMessages; the saved message shows up on its own |
Tools without .describe() on args | The model guesses what each field means and calls tools badly | Describe every zod field |
| Tool handler with no return type annotation | TypeScript circularity errors from ctx.runQuery | Add : Promise<...> to the handler |
| Exposing the message query with no auth check | Any client can read any thread | Call an authorizeThreadAccess helper first |
stopWhen left at the default with tools defined | The model calls a tool and stops without a text reply | Set stopWhen: stepCountIs(n) with n > 1 |
app.use(agent) in convex.config.ts and npx convex dev has runnpx convex env set, never in client codeAgent has a name, languageModel, and instructionsinternalAction with promptMessageIdv.string()listUIMessages.describe() and the handler has a return typestopWhen: stepCountIs(n) set when tools are in playuseUIMessages rather than reading action return values// convex/agent.ts
import { Agent, stepCountIs } from "@convex-dev/agent";
import { openai } from "@ai-sdk/openai";
import { components } from "./_generated/api";
export const supportAgent = new Agent(components.agent, {
name: "Support Agent",
languageModel: openai.chat("gpt-4o-mini"),
instructions: "You are a support assistant. Answer briefly and cite docs when possible.",
// Lets the model call tools and then respond, up to 5 steps
stopWhen: stepCountIs(5),
});// convex/chat.ts
import { v } from "convex/values";
import { mutation, internalAction, QueryCtx, MutationCtx } from "./_generated/server";
import { components, internal } from "./_generated/api";
import { saveMessage } from "@convex-dev/agent";
import { supportAgent } from "./agent";
// Throws unless the signed in user owns the thread
async function authorizeThreadAccess(ctx: QueryCtx | MutationCtx, threadId: string) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const thread = await ctx.runQuery(components.agent.threads.getThread, { threadId });
if (!thread || thread.userId !== identity.subject) throw new Error("Unauthorized");
}
export const startThread = mutation({
args: {},
returns: v.string(),
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Not authenticated");
const { threadId } = await supportAgent.createThread(ctx, { userId: identity.subject });
return threadId;
},
});
export const sendMessage = mutation({
args: { threadId: v.string(), prompt: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
await authorizeThreadAccess(ctx, args.threadId);
const { messageId } = await saveMessage(ctx, components.agent, {
threadId: args.threadId,
prompt: args.prompt,
});
await ctx.scheduler.runAfter(0, internal.chat.generateReply, {
threadId: args.threadId,
promptMessageId: messageId,
});
return null;
},
});
export const generateReply = internalAction({
args: { threadId: v.string(), promptMessageId: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// promptMessageId makes retries safe: the same prompt is reused, never duplicated
await supportAgent.generateText(
ctx,
{ threadId: args.threadId },
{ promptMessageId: args.promptMessageId },
);
return null;
},
});// convex/chat.ts (continued)
import { paginationOptsValidator } from "convex/server";
import { listUIMessages } from "@convex-dev/agent";
import { query } from "./_generated/server";
export const listMessages = query({
args: { threadId: v.string(), paginationOpts: paginationOptsValidator },
handler: async (ctx, args) => {
await authorizeThreadAccess(ctx, args.threadId);
return await listUIMessages(ctx, components.agent, args);
},
});// src/Chat.tsx
import { useUIMessages } from "@convex-dev/agent/react";
import { api } from "../convex/_generated/api";
function Chat({ threadId }: { threadId: string }) {
const { results, status, loadMore } = useUIMessages(
api.chat.listMessages,
{ threadId },
{ initialNumItems: 20 },
);
return (
<div>
{results.map((m) => (
<div key={m.key} data-role={m.role}>{m.text}</div>
))}
{status === "CanLoadMore" && <button onClick={() => loadMore(20)}>Older</button>}
</div>
);
}// convex/tools.ts
import { createTool } from "@convex-dev/agent";
import { z } from "zod";
import { api } from "./_generated/api";
export const searchOrders = createTool({
description: "Find the current user's orders that match a search term",
args: z.object({
term: z.string().describe("Product name or order number to look for"),
}),
handler: async (ctx, args): Promise<Array<{ id: string; status: string }>> => {
return await ctx.runQuery(api.orders.search, { term: args.term });
},
});