お久しぶりです。ひさふるです。
ここ最近、ずっとAIエージェント開発系のお仕事をさせていただくことが多く、個人開発も含めいつも使っている構成があるので、メモがてらご紹介します。
定番のMastra含め、ライブラリ自体の更新が早いのでこれが決定版!とまではいかないものの、AIエージェントを組み込んだWebアプリを作るなら1つの答えになると思うので、ぜひご活用ください。
採用技術
今回は、タイトルの通りNext.js+Mastra+AI SDK+ai elementsの構成を使っていきたいと思います。
Next.js
Next.jsはVercel社が開発している、ReactベースのWebフレームワークです。
クライアント側だけのReactと異なり、サーバー側まで一緒のフレームワークで開発できることが魅力で、近年の比較的小規模なプロジェクトでは採用されることが多い印象です。
今回はNext.jsの内容はある程度知っていただいてる前提で話を進めます。
もしまだNext.jsを触ったことが無ければ、このあたりの記事を参考に学習を進めることをオススメします。
Mastra
最近のAIエージェントフレームワークと言えば、やっぱりMastraですよね。
Mastraはツール呼び出しなどAIエージェント開発に必要な要素が一通り揃っており、今回ご紹介するNext.jsやAI SDK等とも組み合わせやすいことから、Web系のAIエージェント開発では人気のフレームワークとなっています。
今回はNext.jsのバックエンド側にMastraを使い、ツール呼び出しを伴うAIエージェントの開発に使用します。
AI SDK (Vercel AI SDK)
Next.jsと同様、Vercel社によって開発されたAIフレームワークです。
「Mastraと何が違うの?」と思うかもしれませんが、実際両者はかなり近い立ち位置にありつつ、明確に使いわけができるようになっています。
AI SDKはAIモデルへの通信・接続にはじまりクライアント側(UI)へのストリーミングや、エージェントによるツール呼び出しまでを包括的に定義することができます。
対してMastraは、上記の中におけるAIエージェントの定義と実行にめっぽう強いというイメージで、簡単なAIアプリならAI SDKで十分ですが、Mastraと組み合わせることで複雑なエージェントの定義も可能になります。
AI Elements
AI ElementsはAI関連、特にチャット系のUIコンポーネントライブラリで、実態としてはAI SDKの一部となっています。
チャット系のAIでよくあるプロンプト入力欄やモデルセレクタなど、ChatGPT等で良く見るUIコンポーネントが一通り用意されており、このタイプのアプリケーションを使うなら非常にラクになります。
基盤としてTailwind CSS、Shadcn/uiを用いているため、必然的にこの構成を採用することになります。
Hono
AIと関係ありませんが、今回バックエンド側のAPI実装にはHonoを採用したのでこちらもご紹介しておきます。
Honoはここ数年で急速に人気を獲得しているWebフレームワークで、とにかく軽量で高速なのが魅力です。
作るもの
概要
今回は、こんな感じのAIエージェントを作ってみます。
チャットで対話できるAIでありつつ、"Canvas"に読み書きするツールを持たせ、ドキュメントなどを一緒に作ってくれるような想定です。
GeminiやChatGPT、Claudeでも実装されている、"よくある機能"ですね。
ソースコード
今回作ったものは以下のリポジトリに格納しておきました。実際に中のファイルを参照しながら読んでいただけると、理解がより深まるかと思います。
今回のコードのほとんどはCodexを用いて構築しています。主要部分については目を通しているつもりですが、セキュリティ等の観点で詳細なレビューは行っていないため、あくまで参考とし、使用・流用は自己責任でお願いいたします。
クライアント側の実装
ではまずクライアント側からの説明から。
src/app/page.tsx
今回ページはチャット画面だけ。大きく分けて左側のConversationとPromptInputから成るチャット部、右側のCanvas部に分けられます。
必要な部分だけかいつまんで書くとこんな感じ。
src/app/page.tsx (長いので折りたたみ)
(コード全体はGitHub上からご覧ください)
export default function Home() {
// チャット状態、Canvas内容、送信処理はhook側にまとめ、ここでは画面構成に集中する。
const {
canvasContent,
clearError,
error,
handlePromptSubmit,
messages,
setCanvasContent,
showResponseLoading,
status,
stop,
} = useCanvasChat();
return (
<main className="grid h-dvh w-full grid-cols-2 overflow-hidden bg-background">
{/* 左側は会話履歴と入力欄。AI ElementsのConversation/Message/PromptInputを組み合わせる。 */}
<section className="flex min-w-0 flex-col border-border border-r p-4">
<Conversation className="min-h-0 flex-1">
<ConversationContent className="gap-4 px-0 py-0">
messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent>
{message.parts.map((part, index) =>
<MessagePart
key={index}
part={part}
role={message.role}
/>
)}
</MessageContent>
</Message>
))
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<PromptInput
onSubmit={async ({ files, text }) =>
handlePromptSubmit({ files, text })
}
>
<PromptInputBody>
<PromptInputTextarea placeholder="メッセージを入力" />
</PromptInputBody>
<PromptInputFooter>
<div />
<PromptInputSubmit
onClick={(event) => {
// エラー状態では送信ボタンを「エラー解除」として扱い、誤送信を避ける。
if (status === "error") {
event.preventDefault();
clearError();
}
}}
onStop={stop}
status={status}
/>
</PromptInputFooter>
</PromptInput>
</section>
{/* 右側は単一Canvas。今回はDB保存せず、クライアントstateとして保持する。 */}
<section className="flex min-w-0 flex-col bg-muted/20">
<div className="border-border border-b px-4 py-3">
<h1 className="font-medium text-sm">Canvas</h1>
</div>
<Textarea
className="min-h-0 flex-1 resize-none rounded-none border-0 bg-transparent p-4 font-mono text-sm shadow-none focus-visible:ring-0"
onChange={(event) => setCanvasContent(event.target.value)}
placeholder="Canvas"
value={canvasContent}
/>
</section>
</main>
);
}
ここで重要なのは、チャットとしてのメッセージ管理は後述するuseCanvasChatで定義されているということと、チャット画面をConversationとPromptInputで構築しているということです。
useCanvasChatは後で詳しく説明しますが、AI SDKのuseChatをラップしたもので、チャット欄のメッセージやその状態、更に今回実装する"Canvas"の内容などを管理しています。
メッセージはAI ElementsのConversationやMessageを使って表示しています。これらのコンポーネントを使うだけでそれっぽく表示できるのは嬉しいですね。
メッセージ(プロンプト)の入力は、これまたAI ElementsのコンポーネントであるPrompt Inputで受け付けます。
今回は簡素な実装ですが、Model SelectorやSuggestionといったコンポーネントと組み合わせることで、よりリッチなUIに仕立て上げることもできます。
src/hooks/use-canvas-chat.ts
page.tsxで使っていたuseCanvasChatを定義しているのがこちらのファイル。
もともと、AI SDKにはuseChatというチャットを構築するのに便利なhooksがあり、それをCanvasを扱えるように拡張したものです。
コードはこちらから↓
src/hooks/use-canvas-chat.ts
"use client";
import { useChat } from "@ai-sdk/react";
import type { FileUIPart } from "ai";
import { useCallback, useState } from "react";
import type { CanvasChatMessage } from "@/types/canvas-chat";
type PromptMessage = {
files: FileUIPart[];
text: string;
};
// チャットUIと右側Canvasをつなぐための状態管理をまとめたhook。
export const useCanvasChat = () => {
const [canvasContent, setCanvasContent] = useState("");
const { clearError, error, messages, sendMessage, status, stop } =
useChat<CanvasChatMessage>({
onData: (part) => {
// サーバーがwriteCanvasの結果をdata-canvasとして流したら、右側Canvasを更新する。
if (part.type === "data-canvas") {
setCanvasContent(part.data.content);
}
},
});
const lastMessage = messages.at(-1);
const lastAssistantMessageHasText =
lastMessage?.role === "assistant" &&
lastMessage.parts.some(
(part) => part.type === "text" && part.text.length > 0
);
const lastAssistantMessageIsEmpty =
lastMessage?.role === "assistant" && !lastAssistantMessageHasText;
// 送信直後、またはassistantメッセージが作られても本文がまだ空の間だけローディングを出す。
const showResponseLoading =
status === "submitted" ||
(status === "streaming" &&
(lastMessage?.role === "user" || lastAssistantMessageIsEmpty));
const handlePromptSubmit = useCallback(
async ({ files, text }: PromptMessage) => {
const trimmed = text.trim();
if (!trimmed && files.length === 0) {
return;
}
clearError();
// Canvasはクライアントstateなので、送信時点の内容を毎回APIへ同梱する。
await sendMessage(
{ files, text: trimmed },
{ body: { canvas: { content: canvasContent } } }
);
},
[canvasContent, clearError, sendMessage]
);
return {
canvasContent,
clearError,
error,
handlePromptSubmit,
messages,
setCanvasContent,
showResponseLoading,
status,
stop,
};
};
今回、Canvasが入ったことで少し複雑になっているので、まずuseChatから説明していきます。
useChatは以下のように定義でき、指定されたチャット用エンドポイントと通信を行い、その状態や結果を提供します。
エンドポイントのデフォルト値は/api/chatであり、今回も/api/chatで定義しているため記述は省略しています。
const { clearError, error, messages, sendMessage, status, stop } =
useChat<CanvasChatMessage>({
onData: (part) => {
// サーバーがwriteCanvasの結果をdata-canvasとして流したら、右側Canvasを更新する。
if (part.type === "data-canvas") {
setCanvasContent(part.data.content);
}
},
});
使い方をざっくりと説明すると、sendMessage関数でメッセージ(基本的にはユーザーが入力したプロンプト)を送信することができ、送信したメッセージやAIからのレスポンスはmessagesに格納されます。
sendMessageを使うとストリーミング形式での通信が始まりますが、その間の状態はstatusで取得することができ、これが例えばstreamingなら「AIが思考中です...」のようなメッセージを表示する、といった制御に使えます。
sendMessageは次のように使うことができます。
await sendMessage(
{ files, text: trimmed },
{ body: { canvas: { content: canvasContent } } }
);
新たに送信したいファイルやテキストをfilesやtext(今回はtrimmedとして定義)で送ることができます。
過去のメッセージ全体はmessagesで管理されているため、新規で追加したい内容のみでOKです。
今回はCanvasの内容をローカルで管理しており、更にユーザーが編集可能にしているため、メッセージ送信時に最新のCanvasの状態をbodyに格納して一緒に送信しています。
クライアント側のまとめ
クライアント側はAI ElementsのコンポーネントとAI SDKのuseChatを組み合わせることで、複雑なAIエージェントアプリを比較的簡単に構築することができます。
特にAI Elementsは、コンポーネントを自然に導入しているうちにChatGPTなどで見慣れたUIが完成するためオススメです。
バックエンド側の実装
では、いよいよ本命のバックエンドでのAIエージェント実装に移りましょう。
src/server/hono.ts
このファイル自体はあんまり説明することがないですね。
/api/chatエンドポイントを定義しており、実際の処理の中身はhandleChatに書かれています。
先ほどのuseChatからのリクエストをここで受け付けるイメージです。
import { Hono } from "hono";
import { handleChat } from "./routes/chat";
// ここではエンドポイント定義だけを持ち、各処理の中身はroutes配下に分離する。
const app = new Hono().basePath("/api");
app.post("/chat", handleChat);
export default app;
src/server/routes/chat.ts
ここで定義されているhandleChatが、クライアント側から送られて来た情報をAIエージェントに受け渡し、その実行結果をレスポンスとして返しています。
色々と書いてありますが、重要部分だけかいつまんで説明していきます。
まず、↓このあたりでクライアント側のsendMessageで送信していたメッセージの全文や、Canvasの内容を受け取ります。
const body = await c.req.json<ChatRequestBody>().catch(() => null);
const messages = body?.messages;
const canvasContent = body?.canvas?.content ?? "";
そしてここからが重要で、mastra.getAgent("chatAgent");という部分で定義済みのAIエージェントを呼び出しています(エージェントの詳細は後述)。
const agent = mastra.getAgent("chatAgent");
const stream = createUIMessageStream<CanvasChatMessage>({
originalMessages: messages as CanvasChatMessage[],
execute: async ({ writer }) => {
// 省略
}
})
return createUIMessageStreamResponse({ stream });
更に、createUIMessageStreamでCanvasChatMessage型を持つデータのストリームを作成しています。
CanvasChatMessageは今回のために新しく定義した、通常のメッセージに加えCanvasのデータを持つ型ですが、この型をクライアントのuseChatとこのcreateUIMessageStreamの両方で参照することで、型安全なストリーム通信を行うことができるのがこの構成の威力です。
最後に、createUIMessageStreamResponse({ stream });でストリーム形式のレスポンスを作成してクライアントに返します。
では次に、createUIMessageStreamの詳細な処理の流れを追ってみましょう。
createUIMessageStreamのexecute内に、AIエージェント呼び出しの過程が書かれています。
const requestContext = new RequestContext<{ canvasContent: string }>();
// readCanvas toolが現在のCanvasを読めるように、リクエスト単位の文脈へ入れる。
requestContext.set("canvasContent", canvasContent);
const result = await agent.stream(messages as MessageListInput, {
abortSignal: c.req.raw.signal,
maxSteps: 4,
requestContext,
});
const [agentStream, canvasStream] = result.fullStream.tee();
writer.merge(toCanvasUIMessageStream(agentStream));
await streamCanvasUpdates(canvasStream, writer);
色々とやっていますが、重要なのは2点です。
まず1点目として、requestContext.setでCanvas情報を格納しています。
Request contextは、エージェントやツール呼び出しの際に渡すことができる値であり、今回のCanvas情報のようなメッセージ本文以外で渡したい情報があるときに使います。
2点目は、agent.stream(messages as MessageListInput ...の部分でAIエージェントを呼び出しています。
ここで渡されているmaxStepsはツール呼び出し等を含めてAIエージェントが最大何回実行できるかを定義するものです。
多ければ多いほどAIエージェントがよく考えて行動できるようになりますが、レスポンスやトークン使用量が増加し、制限をかけないと無限ループする可能性も出てきてしまいます。
先ほど定義したrequestContextもここで渡します。
AIエージェント呼び出しの結果からは、canvasStreamが得られる(というか今回は得られるように実装している)ので、これをstreamCanvasUpdatesという関数で通常のメッセージと共に返しています。
src/server/mastra/agent.ts
続いて、上記で呼び出しているエージェントの内容をご紹介します。
エージェントの定義ファイルは比較的簡素で、Mastraを使って定義しています。
import { openai } from "@ai-sdk/openai";
import { Agent } from "@mastra/core/agent";
import { readCanvasTool, writeCanvasTool } from "./canvas-tools";
// Canvas上の単一ドキュメントを、ユーザーと一緒に作り上げるためのagent。
export const chatAgent = new Agent({
id: "chat-agent",
name: "Chat Agent",
instructions:
"あなたは、ユーザーと一緒にCanvas上でドキュメントを作り上げるアシスタントです。会話では簡潔に日本語で返答してください。Canvasには現在編集中の単一ドキュメントがあります。ユーザーが下書き、追記、編集、書き換え、構成変更、要約の反映などを依頼した場合は、必要に応じてreadCanvasで現在の内容を確認し、writeCanvasでCanvas全体を更新してください。Canvasを更新した後は、変更内容を短く説明してください。Canvasを更新しない場合は、会話で相談や提案だけを行ってください。",
model: openai(process.env.MASTRA_MODEL ?? "gpt-5.5"),
tools: {
readCanvas: readCanvasTool,
writeCanvas: writeCanvasTool,
},
});
new Agentに対してシステムプロンプト(instructions)や使用するモデル(model)、エージェントが使えるツール(tools)を与えているだけです。
システムプロンプトの内容としては、単に「ユーザーの指示に応じてCanvasでドキュメントを一緒に作って」という旨の内容のみ与えています。
src/server/mastra/canvas-tools.ts
エージェントに与えているreadCanvasToolとwriteCanvasToolというツールは、こちらのファイルで定義されています。
たとえば、readであればこんな感じです。
export const readCanvasTool = createTool({
id: "readCanvas",
description: "Read the current content of the single Canvas document.",
outputSchema: canvasOutputSchema,
execute: async (_input, context) => {
const requestContext =
context.requestContext as
| {
get: <T>(key: keyof CanvasRequestContext) => T;
}
| undefined;
return {
content: requestContext?.get<string>("canvasContent") ?? "",
};
},
});
MastraではcreateToolでツールを定義することができます。
ここで重要なのがdescriptionで、これは人間向けの説明ではなくAIエージェント向けの説明文であることに注意が必要です。
今回であれば「Read the current content of the single Canvas document. (単一のキャンバスドキュメントの現在の内容を読むツール)」という説明がなされており、これを読んでAIエージェントはどのツールを使えば良いか判断します。
ツールの内容は単に、requestContext内のcanvasContentを返しているだけですね。
writeの場合は事実上特に何もしていませんが、このツール呼び出しの内容を後から解析し、最新のコンテンツの内容を取り出してクライアントに返すようになっています。
このread/writeのツールを用いて、AIエージェントはCanvasの内容を読み書きできるようになります。
バックエンド側のまとめ
バックエンド側では、Honoを使ったエンドポイント定義に始まり、AI SDKを使ってストリーム通信を行ったり、Mastraでツールを持ったAIエージェントを定義する様子を追ってきました。
Canvasが入ったことでちょっと複雑なところもありましたが肝心のAIエージェントの定義自体は非常に簡素であり、誰でも簡単に構築できるようになっています。
おわりに
今回はNext.js+Mastra+AI SDK+AI Elementsの構成でAIエージェントを作ってみました。
慣れてしまえば非常に簡単に高度なAIエージェントを開発することができるようになります。
使っているライブラリはアップデート頻度が高くついていくのが大変ですが、その分様々な機能が現在進行系で追加されているので、今後の発展も楽しみです。
皆様もぜひ、自分だけのAIエージェントを開発してみてはいかがでしょうか?

