convex-agents について
Convexと`@convex-dev/agent`を使い、チャット型またはツール呼び出し型のエージェントを構築するスキルです。会話履歴をConvexに保存し、アクション内でモデル呼び出しを実行して、リアクティブなメッセージ表示、ツール、検索、永続的な複数段階のジョブを扱えます。
主な機能
- 01会話スレッドの永続化
- 02リアクティブなメッセージ一覧
- 03ツール経由のクエリとミューテーション呼び出し
- 04ドキュメント検索ワークフロー
- 05永続的な複数段階のLLMジョブ
活用シーン
- 01履歴付きチャットの追加
- 02クライアントへの返信ストリーミング
- 03ツール経由でアプリ機能をLLMに操作させる
- 04ドキュメント質問応答の構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ waynesutton/ builder-skills --skill convex-agentsインストール後、Claude Code で/convex-agentsを入力して読み込まれたか確認します。
詳細
Convex エージェント
@convex-dev/agent を利用したチャットエージェントまたはツール呼び出しエージェントを生成します。スレッド履歴は Convex に保存され、UI ではリアクティブなメッセージ一覧を表示できます。ルールはひとつです。すべての LLM 呼び出しはアクション内で実行します。ミューテーションはプロンプトを保存してアクションをスケジュールするだけで、モデルを呼び出すことはありません。
こんな場合に使います
- 会話履歴を永続化するチャットアシスタントを追加する
- LLM がクエリやミューテーションをツールとして呼び出せるようにする
- モデルの応答を 1 つ以上のクライアントにストリーミングする
- 独自のドキュメントに関する質問に回答する(RAG)
- 複数の LLM ステップをつなぎ、再起動後も継続する永続的なジョブにする
インストールと登録
npm install @convex-dev/agent ai @ai-sdk/openai zod
npx convex env set OPENAI_API_KEY sk-...
// convex/convex.config.ts
import { defineApp } from "convex/server";
import agent from "@convex-dev/agent/convex.config";
const app = defineApp();
app.use(agent);
export default app;
エージェントを定義する前に、components.agent が生成されるよう npx convex dev を一度実行します。
エージェントを定義する
// 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),
});
name は保存される各メッセージに、それを書いたエージェントのタグを付けます。name 以外は、すべて呼び出しごとに上書きできます。
スレッドを作成して応答を生成する
ユーザーのプロンプトをミューテーションで保存し、応答を生成する内部アクションをスケジュールします。スレッドを購読しているクライアントには、アクションが何も返さなくても新しいメッセージが表示されます。
// 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;
},
});
スレッド ID は文字列です。テーブルはコンポーネント内にあるため、v.id(...) ではありません。
UI 用のメッセージを一覧表示する
// 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>
);
}
listUIMessages はツール呼び出しとその後に続くアシスタントのテキストを 1 つの UIMessage にまとめるため、レンダリングをシンプルに保てます。
ひとつのツール
ツールは createTool で定義し、runQuery、runMutation、userId、threadId を含む ctx を受け取ります。循環型エラーを避けるため、ハンドラーの戻り値の型を注釈してください。
// 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 });
},
});
コンストラクターまたは呼び出し箇所で tools: { searchOrders } を指定して、エージェントに渡します。ツールのエラー処理、クロージャーを使うランタイムツール、クライアントへの差分ストリーミングについては、references/tools-and-streaming.md をご覧ください。
検索と複数ステップのジョブ
ドキュメントの埋め込み、@convex-dev/rag または独自に実装したベクトルインデックスによる検索、結果のプロンプトへの挿入、複数の LLM ステップを永続的な @convex-dev/workflow ジョブとして実行する方法については、references/rag-and-workflows.md をご覧ください。
よくある間違い
| 間違い | 問題が起きる理由 | 代わりに行うこと |
|---|---|---|
ミューテーション内で generateText を呼び出す | ミューテーションはネットワーク呼び出しができず、決定的でなければならないため | saveMessage でプロンプトを保存し、internalAction をスケジュールする |
スレッド ID に v.id("threads") を使う | スレッドテーブルはコンポーネント内にあるため、その外では ID は文字列になる | v.string() を使う |
app.use(agent) の後に npx convex dev を実行しない | components.agent が生成されず、型エラーが発生するため | エージェントのコードを書く前に dev を一度実行する |
| アクションからクライアントに応答テキストを返す | クライアントが切断すると応答が失われ、リアクティブにならないため | クライアントに listUIMessages を読み込ませる。保存されたメッセージは自動的に表示される |
引数に .describe() がないツール | モデルが各フィールドの意味を推測するため、ツールを適切に呼び出せない | すべての zod フィールドに説明を付ける |
| 戻り値の型注釈がないツールハンドラー | ctx.runQuery によって TypeScript の循環性エラーが発生するため | ハンドラーに : Promise<...> を追加する |
| 認証チェックなしでメッセージクエリを公開する | どのクライアントからでも任意のスレッドを読み取れるため | 最初に authorizeThreadAccess ヘルパーを呼び出す |
ツールを定義したまま stopWhen をデフォルトにする | モデルがツールを呼び出した後、テキストの応答を返さずに停止するため | n > 1 として stopWhen: stepCountIs(n) を設定する |
チェックリスト
-
convex.config.tsにapp.use(agent)を設定し、npx convex devを実行済みです - プロバイダーキーは
npx convex env setで保存し、クライアントコードには決して記述しません -
Agentにname、languageModel、instructionsを設定します - プロンプトはミューテーションで保存し、
promptMessageIdを指定してinternalActionで返信を生成します - スレッド ID とメッセージ ID の型を
v.string()にします - メッセージ一覧のクエリで、
listUIMessagesを呼び出す前にスレッドの所有権を確認します - すべてのツール引数に zod の
.describe()を指定し、ハンドラーに戻り値の型を指定します - ツールを使用する場合は、
stopWhen: stepCountIs(n)を設定します - クライアントでは、アクションの戻り値を読み取るのではなく
useUIMessagesを使用します