このチュートリアルでは、話しかけられる音声エージェントを作ります。エージェントは AI Search のナレッジベースから声で答えます。Cloudflare Agents の @cloudflare/voice パッケージで音声パイプラインを構成し、AI Search をエージェントのナレッジベースとして公開します。モデルが呼び出す検索ツールです。
作るもの: 発話を文字起こしし、AI Search でインデックス済みナレッジベースから関連コンテンツを取得し、根拠のある回答を生成して、声で返す音声エージェントです。
@cloudflare/voice パッケージは、Cloudflare Agent に音声パイプライン一式を追加します。speech-to-text (STT)、応答を作る「ターン」ハンドラー、text-to-speech (TTS) です。パイプラインは Durable Object を裏にした 1 つの Worker で動き、ブラウザーは WebSocket で接続します。
書くメソッドは onTurn() だけです。ユーザーの文字起こしを受け取り、読み上げるテキストを返します。ここに AI Search を組み込みます。言語モデルを実行し、AI Search を検索ツールとして渡します。モデルはナレッジベースを検索するタイミングを決め、返ってきた結果に基づいて応答を作り、回答テキストを返します。パイプラインがそのテキストを読み上げます。
- Cloudflare アカウント ↗ に登録します。
Node.js↗ をインストールします。
Node.js のバージョンマネージャー
権限の問題を避け、Node.js のバージョンを切り替えられるよう、Volta ↗ や nvm ↗ などの Node バージョンマネージャーを使います。このガイドの後半で説明する Wrangler には、Node バージョン 16.17.0 以降が必要です。
インデックス済みコンテンツを含む AI Search インスタンスも必要です。これが話しかけるナレッジベースになります。作成とコンテンツ追加は はじめに を参照してください。
Durable Object の配線と React クライアントを含む、音声スターターテンプレートで Cloudflare Agents プロジェクトを作成します。
npm create cloudflare@latest voice-knowledge-base -- --template cloudflare/agents-starter
cd voice-knowledge-base音声パッケージをインストールします。
npm i @cloudflare/voiceyarn add @cloudflare/voicepnpm add @cloudflare/voicebun add @cloudflare/voice@cloudflare/voice パッケージは withVoice mixin と、Workers AI プロバイダー(WorkersAIFluxSTT と WorkersAITTS)を提供します。ブラウザークライアントを含む音声エージェント本体の詳細は、音声エージェントの例 を参照してください。
Wrangler 設定ファイル に AI Search バインディング を追加します。Workers AI バインディングとエージェントの Durable Object と並べます。my-instance はインスタンス名に置き換えます。
{
"name": "voice-knowledge-base",
"main": "src/server.ts",
// Set this to today's date
"compatibility_date": "2026-09-20",
"compatibility_flags": ["nodejs_compat"],
"ai": {
"binding": "AI",
"remote": true
},
"ai_search": [
{
"binding": "AI_SEARCH",
"instance_name": "my-instance",
"remote": true
}
],
"durable_objects": {
"bindings": [
{
"name": "TalkToDocs",
"class_name": "TalkToDocs"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["TalkToDocs"]
}
]
}name = "voice-knowledge-base"
main = "src/server.ts"
# Set this to today's date
compatibility_date = "2026-09-20"
compatibility_flags = [ "nodejs_compat" ]
[ai]
binding = "AI"
remote = true
[[ai_search]]
binding = "AI_SEARCH"
instance_name = "my-instance"
remote = true
[[durable_objects.bindings]]
name = "TalkToDocs"
class_name = "TalkToDocs"
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "TalkToDocs" ]env.AI と env.AI_SEARCH が型付けされるよう、バインディング型を再生成します。
npx wrangler typesyarn wrangler typespnpm wrangler typessrc/server.ts を更新します。withVoice mixin でエージェントを組み立て、STT と TTS のプロバイダーを設定し、onTurn() で Workers AI モデルを実行します。モデルは AI Search を検索ツールとして呼び出します。
モデルには、AI Search の search() を呼び出して検索する searchKnowledgeBase ツールを渡します。ナレッジベースの事実が必要なときにツールを呼び、返ってきたチャンクに基づいて回答し、生成したテキストをパイプラインが読み上げるよう返します。エージェントは会話履歴を自動で保存するため、フォローアップの質問には context.messages を渡せます。
import { Agent, routeAgentRequest } from "agents";
import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "@cloudflare/voice";
import { generateText, tool, stepCountIs } from "ai";
import { createWorkersAI } from "workers-ai-provider";
import { z } from "zod";
const VoiceAgent = withVoice(Agent);
export class TalkToDocs extends VoiceAgent {
// Workers AI powers speech-to-text and text-to-speech (no API keys needed).
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
// Called each time the user finishes speaking. The agent's model generates
// the reply, calling AI Search as a retrieval tool when it needs facts from
// your knowledge base.
async onTurn(transcript, context) {
const workersai = createWorkersAI({ binding: this.env.AI });
const result = await generateText({
// Use a Workers AI model that supports function calling.
model: workersai("@cf/zai-org/glm-5.2"),
system:
"You are a helpful voice assistant that answers from a Cloudflare AI Search knowledge base. " +
"For questions about the product, call the searchKnowledgeBase tool first and answer using the results. " +
"Skip the tool for greetings and small talk. Keep replies short and conversational.",
messages: [
...context.messages.map((message) => ({
role: message.role,
content: message.content,
})),
{ role: "user", content: transcript },
],
tools: {
searchKnowledgeBase: tool({
description:
"Search the knowledge base for information to answer the user's question.",
inputSchema: z.object({
query: z.string().describe("A focused search query"),
}),
execute: async ({ query }) => {
// search() runs retrieval only and returns the matching chunks.
const res = await this.env.AI_SEARCH.search({
query,
ai_search_options: { retrieval: { max_num_results: 5 } },
});
return res.chunks.map((chunk) => chunk.text).join("\n\n");
},
}),
},
// Let the model call the tool, then answer from the results.
stopWhen: stepCountIs(4),
});
// Return the generated answer for the pipeline to speak.
return result.text;
}
}
export default {
async fetch(request, env) {
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
};import { Agent, routeAgentRequest } from "agents";
import {
withVoice,
WorkersAIFluxSTT,
WorkersAITTS,
type VoiceTurnContext,
} from "@cloudflare/voice";
import { generateText, tool, stepCountIs } from "ai";
import { createWorkersAI } from "workers-ai-provider";
import { z } from "zod";
const VoiceAgent = withVoice(Agent);
export class TalkToDocs extends VoiceAgent<Env> {
// Workers AI powers speech-to-text and text-to-speech (no API keys needed).
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
// Called each time the user finishes speaking. The agent's model generates
// the reply, calling AI Search as a retrieval tool when it needs facts from
// your knowledge base.
async onTurn(transcript: string, context: VoiceTurnContext) {
const workersai = createWorkersAI({ binding: this.env.AI });
const result = await generateText({
// Use a Workers AI model that supports function calling.
model: workersai("@cf/zai-org/glm-5.2"),
system:
"You are a helpful voice assistant that answers from a Cloudflare AI Search knowledge base. " +
"For questions about the product, call the searchKnowledgeBase tool first and answer using the results. " +
"Skip the tool for greetings and small talk. Keep replies short and conversational.",
messages: [
...context.messages.map((message) => ({
role: message.role as "user" | "assistant",
content: message.content,
})),
{ role: "user" as const, content: transcript },
],
tools: {
searchKnowledgeBase: tool({
description:
"Search the knowledge base for information to answer the user's question.",
inputSchema: z.object({
query: z.string().describe("A focused search query"),
}),
execute: async ({ query }) => {
// search() runs retrieval only and returns the matching chunks.
const res = await this.env.AI_SEARCH.search({
query,
ai_search_options: { retrieval: { max_num_results: 5 } },
});
return res.chunks.map((chunk) => chunk.text).join("\n\n");
},
}),
},
// Let the model call the tool, then answer from the results.
stopWhen: stepCountIs(4),
});
// Return the generated answer for the pipeline to speak.
return result.text;
}
}
export default {
async fetch(request: Request, env: Env) {
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
},
} satisfies ExportedHandler<Env>;search() が返す各チャンクにはソースアイテムと関連度スコアが含まれるため、引用を出したり、エージェントが取得した内容を記録したりできます。
src/client.tsx を、useVoiceAgent フックを使う React コンポーネントに置き換えます。フックはマイク、エージェントへの WebSocket 接続、音声再生、割り込み検出を管理するため、コンポーネントはコントロールを描画するだけで済みます。agent にはエージェントクラス名 TalkToDocs を設定します。
import { useVoiceAgent } from "@cloudflare/voice/react";
function App() {
// useVoiceAgent connects to your agent over WebSocket, captures the
// microphone, plays the spoken response, and exposes the live call state.
// `agent` matches your Durable Object class name.
const {
// Pipeline state: "idle" | "listening" | "thinking" | "speaking".
status,
// Finalized conversation turns (your speech and the agent's replies).
transcript,
// Live partial transcription of what you are currently saying.
interimTranscript,
// Whether the WebSocket connection to the agent is open.
connected,
startCall,
endCall,
toggleMute,
isMuted,
} = useVoiceAgent({ agent: "TalkToDocs" });
const inCall = status !== "idle";
return (
<div>
<h1>Talk to your knowledge base</h1>
{/* Shows "thinking" while the agent searches the KB and generates a reply. */}
<p>Status: {status}</p>
{/* Toggle the call. Disabled until the agent connection is open. */}
<button
onClick={inCall ? endCall : startCall}
disabled={!connected && !inCall}
>
{!connected ? "Connecting…" : inCall ? "End call" : "Start call"}
</button>
{/* Mute only applies once a call is active. */}
{inCall && (
<button onClick={toggleMute}>{isMuted ? "Unmute" : "Mute"}</button>
)}
{/* Lightweight loading state while the agent works on a reply. */}
{status === "thinking" && <p>Thinking…</p>}
{/* Live partial transcript, updated as you speak. */}
{interimTranscript && (
<p>
<em>{interimTranscript}</em>
</p>
)}
{/* Finalized turns from both you and the agent. */}
{transcript.map((message, index) => (
<p key={index}>
<strong>{message.role}:</strong> {message.text}
</p>
))}
</div>
);
}
export default App;フックがマイクと再生を扱うため、プッシュトゥトークボタンは不要です。モデルは話し終わりを検出し、onTurn() を実行し、音声の回答を自動で再生します。
ローカル開発サーバーを起動します。
npm run devyarn run devpnpm run devブラウザーでアプリを開き、Start call を選んでマイクへのアクセスを許可したあと、コンテンツが答えられる質問をします。発話がリアルタイムで文字起こしされ、エージェントはナレッジベースから声で答えます。処理に合わせて status の値は listening、thinking、speaking と進みます。
エージェントをデプロイし、インターネットから使えるようにします。
npx wrangler deployyarn wrangler deploypnpm wrangler deployこのチュートリアルは、1 人向けの音声エージェントを作ります。複数人が同じルームでナレッジベースに話しかける場合は、マルチパーティの音声と映像に RealtimeKit を使い、AI Search から答える部品としてこの音声エージェントを残します。RealtimeKit は会議室と文字起こしを提供しますが、回答エンジンはホストしません。