Tools
For AI agents: see llms.txt for the complete documentation index. Markdown versions are available by adding .md to a page URL or requesting Accept: text/markdown.
The Agent component supports tool calls, which are a way to allow an LLM to call out to external services or functions. This can be useful for:
- Retrieving data from the database
- Writing or updating data in the database
- Searching the web for more context
- Calling an external API
- Requesting that a user takes an action before proceeding (human-in-the-loop)
Defining tools
You can provide tools at different times:
- Agent constructor: (
new Agent(components.agent, { tools: {...} })) - Creating a thread:
createThread(ctx, { tools: {...} }) - Continuing a thread:
continueThread(ctx, { tools: {...} }) - On thread functions:
thread.generateText({ tools: {...} }) - Outside of a thread:
supportAgent.generateText(ctx, {}, { tools: {...} })
Specifying tools at each layer will overwrite the defaults. The tools will be
args.tools ?? thread.tools ?? agent.options.tools. This allows you to create
tools in a context that is convenient.
Using tools
The Agent component will automatically handle passing tool call results back in
and re-generating if you pass stopWhen: stepCountIs(num) where num > 1 to
generateText or streamText.
The tool call and result will be stored as messages in the thread associated with the source message. See Messages for more details.
Stopping the tool loop
The loop stops after a step that makes no tool calls, after a step with a tool
call still waiting on approval, or when a stopWhen condition matches.
stopWhen, on the call or on the Agent, is the control over this. The Agent's
maxSteps option is a fallback: generateText and streamText turn it into
stepCountIs(maxSteps) only when neither the call nor the Agent sets
stopWhen, and it is not accepted as a call argument. asTextAction is the
exception: a maxSteps argument there replaces stopWhen.
Conditions in an array are ORed, so the loop stops as soon as any of them holds. Pair a tool condition with a step cap, otherwise a model that keeps calling the tool without producing the result you wanted will loop until the action times out:
import { hasSuccessfulToolCall, stepCountIs } from "@convex-dev/agent";
await agent.generateText(
ctx,
{ threadId },
{
prompt,
stopWhen: [hasSuccessfulToolCall("generateImage"), stepCountIs(5)],
},
);
hasSuccessfulToolCall matches only when the tool returned a result. The AI
SDK's hasToolCall matches the call even if the tool fails, which can stop the
loop before the model has a chance to retry.
Creating a tool with a Convex context
There are two ways to create a tool that has access to the Convex context.
- Use the
createToolfunction, which is a wrapper around the AI SDK'stoolfunction.
export const ideaSearch = createTool({
description: "Search for ideas in the database",
inputSchema: z.object({
query: z.string().describe("The query to search for"),
}),
execute: async (ctx, args, options): Promise<Array<Idea>> => {
// ctx has agent, userId, threadId, messageId
// as well as ActionCtx properties like auth, storage, runMutation, and runAction
const ideas = await ctx.runQuery(api.ideas.searchIdeas, {
query: args.query,
});
console.log("found ideas", ideas);
return ideas;
},
});
- Define tools at runtime in a context with the variables you want to use.
async function createTool(ctx: ActionCtx, teamId: Id<"teams">) {
const myTool = tool({
description: "My tool",
inputSchema: z.object({...}).describe("The arguments for the tool"),
execute: async (args, options): Promise<BarReturnType> => {
return await ctx.runQuery(internal.foo.bar, args);
},
});
}
In both cases, the args and options match the underlying AI SDK's tool
function.
If you run into type errors, ensure you're annotating the return type of the
execute function, and if necessary, the return type of the handlers of any
functions you call with ctx.run*.
Note: it's highly recommended to use zod with .describe to provide details
about each parameter. This will be used to provide a description of the tool to
the LLM.
Adding custom context to tools
It's often useful to have extra metadata in the context of a tool.
By default, the context passed to a tool is a ToolCtx with:
agent- the Agent instance calling ituserId- the user ID associated with the call, if anythreadId- the thread ID, if anymessageId- the message ID of the prompt message passed to generate/stream.- Everything in
ActionCtx, such asauth,storage,runQuery, etc. Note: in scheduled functions, workflows, etc, the auth user will benull.
To add more fields to the context, you can pass a custom context to the call,
such as agent.generateText({ ...ctx, orgId: "123" }).
You can enforce the type of the context by passing a type when constructing the Agent.
const myAgent = new Agent<{ orgId: string }>(...);
Then, in your tools, you can use the orgId field.
type MyCtx = ToolCtx & { orgId: string };
const myTool = createTool({
inputSchema: z.object({ ... }),
description: "...",
execute: async (ctx: MyCtx, args) => {
// use ctx.orgId
},
});
Artifacts and canvases
An artifact (a document, a code file, a canvas) can live in your application's tables rather than in message content. One pattern is to store a version per edit and give the model tools that write to it:
// convex/schema.ts
artifacts: defineTable({
threadId: v.string(),
key: v.string(),
version: v.number(),
content: v.string(),
}).index("by_threadId_key_version", ["threadId", "key", "version"]),
const writeArtifact = createTool({
description: "Create or replace the named artifact with new content.",
inputSchema: z.object({ key: z.string(), content: z.string() }),
execute: async (ctx, { key, content }): Promise<{ version: number }> => {
if (!ctx.threadId) throw new Error("writeArtifact requires a thread");
return ctx.runMutation(internal.artifacts.write, {
threadId: ctx.threadId,
key,
content,
});
},
});
The mutation must authorize the write and allocate the next version. The tool
result stays small because it contains only the version, while the client reads
the artifact with useQuery and receives updates reactively. Keeping each
version as a separate row lets the app show earlier edits.
Avoid asking the model to emit the artifact inside special tags in its text. The text is stored verbatim and streamed as it arrives, so a half-written tag is visible to users, and the parse has to be maintained per model. A tool call is validated against a schema and either happens or does not.
Using an LLM or Agent as a tool
You can do generation within a tool call, for instance if you wanted one Agent to ask another Agent a question.
Note: you don't have to structure agents calling each other as tool calls. You could instead decide which Agent should respond next based on other context and have many Agents contributing in the same thread.
The simplest way to model Agents as tool calls is to have each tool call work in an independent thread, or do generation without a thread at all. Then, the output is returned as the tool call result for the next LLM step to use. When you do it this way, you don't need to explicitly save the tool call result to the parent thread.
Direct LLM generation without a thread:
const llmTool = createTool({
description: "Ask a question to some LLM",
inputSchema: z.object({
message: z.string().describe("The message to ask the LLM"),
}),
execute: async (ctx, args, options): Promise<string> => {
const result = await generateText({
instructions: "You are a helpful assistant.",
// Pass through all messages from the current generation
prompt: [...options.messages, { role: "user", content: args.message }],
model: myLanguageModel,
});
return result.text;
},
});
Using an Agent as a tool
const agentTool = createTool({
description: `Ask a question to agent ${agent.name}`,
inputSchema: z.object({
message: z.string().describe("The message to ask the agent"),
}),
execute: async (ctx, args, options): Promise<string> => {
const { userId } = ctx;
const { thread } = await agent.createThread(ctx, { userId });
const result = await thread.generateText(
{
// Pass through all messages from the current generation
prompt: [...options.messages, { role: "user", content: args.message }],
},
// Save all the messages from the current generation to this thread.
{ storageOptions: { saveMessages: "all" } },
);
// Optionally associate the child thread with the parent thread in your own
// tables.
await saveThreadAsChild(ctx, ctx.threadId, thread.threadId);
return result.text;
},
});