note版:https://note.com/mintototo1/n/nbe89d68cd5c5
Zenn版:https://zenn.dev/mintototo1/articles/tutorial-claude-tool-use-nextjs
note 版:https://note.com/mintototo1
16:42 に editor 開いて、17:09 に本番で動いた。
Claude API の tool use、実装27分の全コード公開する。
「どうせ難しいんでしょ」と思ってた俺が、拍子抜けするくらいシンプルだった話。
何を作ったか
SaaS の管理画面に自然言語インターフェースを追加した。「先月の売上見せて」「アクティブユーザー何人?」と入力すると、Claude が DB を叩いて答えを返す。
tool use の仕組みは単純で、Claude に「この関数を呼んでいいよ」と教えると、Claude が判断して引数を組み立てて渡してくる。呼び出し側(俺のコード)がその関数を実際に実行して、結果を Claude に返す。Claude は最終的なテキスト回答を生成する。
構成:
- Next.js 14(App Router)
- @anthropic-ai/sdk 0.20+
- TypeScript
- Supabase(DB)
Step 1:tool の定義
tool は JSON Schema で書く。description が一番大事で、「いつこの tool を呼ぶべきか」「何を返すか」を Claude が理解できる言葉で書く。曖昧な description にすると誤発火や呼び忘れが起きる。
// lib/claude-tools.ts
import Anthropic from '@anthropic-ai/sdk'
export const tools: Anthropic.Tool[] = [
{
name: 'get_revenue',
description:
'指定期間の売上合計・件数を取得する。' +
'月次・週次・日次に対応。期間指定なしの場合は直近12件を返す。',
input_schema: {
type: 'object',
properties: {
period: {
type: 'string',
enum: ['daily', 'weekly', 'monthly'],
description: '集計単位',
},
date_from: {
type: 'string',
description: '開始日(YYYY-MM-DD 形式)',
},
date_to: {
type: 'string',
description: '終了日(YYYY-MM-DD 形式)',
},
},
required: ['period'],
},
},
{
name: 'list_users',
description:
'ユーザー一覧を取得する。ステータス(active/canceled/trial)でフィルタできる。件数上限は100。',
input_schema: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['active', 'canceled', 'trial'],
},
limit: {
type: 'number',
description: '取得件数(最大100、デフォルト20)',
},
},
required: [],
},
},
]
description は「データを取得する」程度じゃダメ。「いつ使うべきか」「何のデータか」「制約は何か」を1〜2文で書く。ここをサボると Claude が迷う。
Step 2:API Route に組み込む
tool_use のコアはループだ。Claude が「tool 使いたい」と返してくる限り、実行→結果返却→再質問を繰り返す。
// app/api/chat/route.ts
import Anthropic from '@anthropic-ai/sdk'
import { tools } from '@/lib/claude-tools'
import { executeToolCall } from '@/lib/tool-handlers'
const client = new Anthropic()
export async function POST(req: Request) {
const { messages } = await req.json()
let currentMessages = [...messages]
let response = await client.messages.create({
model: 'claude-opus-4-6',
max_tokens: 1024,
tools,
messages: currentMessages,
})
// tool_use が返ってきたらハンドラを実行してループ
while (response.stop_reason === 'tool_use') {
const toolUseBlocks = response.content.filter(
(block): block is Anthropic.ToolUseBlock => block.type === 'tool_use'
)
// 複数 tool を並列で実行(Claude は同時に複数呼ぶことがある)
const toolResults: Anthropic.ToolResultBlockParam[] = await Promise.all(
toolUseBlocks.map(async (block) => ({
type: 'tool_result' as const,
tool_use_id: block.id,
content: JSON.stringify(
await executeToolCall(block.name, block.input as Record<string, unknown>)
),
}))
)
// 会話履歴に assistant の発話 + tool_result を追加
currentMessages = [
...currentMessages,
{ role: 'assistant' as const, content: response.content },
{ role: 'user' as const, content: toolResults },
]
response = await client.messages.create({
model: 'claude-opus-4-6',
max_tokens: 1024,
tools,
messages: currentMessages,
})
}
const textBlock = response.content.find(
(block): block is Anthropic.TextBlock => block.type === 'text'
)
return Response.json({ reply: textBlock?.text ?? '' })
}
stop_reason === 'tool_use' でループする。!== 'end_turn' で書くのは危険で、エラー発生時に無限ループする。
Step 3:ハンドラの実装
実際に DB を叩く関数。型を明示して、default ケースも必ず書く。
// lib/tool-handlers.ts
import { supabase } from '@/lib/supabase'
export async function executeToolCall(
name: string,
input: Record<string, unknown>
): Promise<unknown> {
switch (name) {
case 'get_revenue': {
const { period, date_from, date_to } = input as {
period: 'daily' | 'weekly' | 'monthly'
date_from?: string
date_to?: string
}
const { data, error } = await supabase.rpc('get_revenue_by_period', {
p_period: period,
p_from: date_from ?? null,
p_to: date_to ?? null,
})
if (error) return { success: false, error: error.message }
return { success: true, data, period }
}
case 'list_users': {
const { status, limit = 20 } = input as {
status?: string
limit?: number
}
let query = supabase
.from('users')
.select('id, email, status, created_at')
.order('created_at', { ascending: false })
.limit(Math.min(limit, 100))
if (status) query = query.eq('status', status)
const { data, error } = await query
if (error) return { success: false, error: error.message }
return { success: true, data, count: data?.length }
}
default:
// ハルシネーション対策:存在しない tool が呼ばれた時
return { success: false, error: `Unknown tool: ${name}` }
}
}
default ケースは必須。Claude が稀に存在しない tool を呼ぶことがある。その時に undefined を返すと会話が破綻する。
Step 4:動作確認スクリプト
本番に組む前に単体で確認する。これを先に動かして tool_use の挙動を体感する。
// scripts/test-tool-use.ts
import Anthropic from '@anthropic-ai/sdk'
const client = new Anthropic()
const response = await client.messages.create({
model: 'claude-opus-4-6',
max_tokens: 512,
tools: [
{
name: 'get_current_time',
description: '指定タイムゾーンの現在時刻を取得する',
input_schema: {
type: 'object',
properties: {
timezone: {
type: 'string',
description: 'タイムゾーン(例:Asia/Tokyo)',
},
},
required: ['timezone'],
},
},
],
messages: [{ role: 'user', content: '今の東京の時刻は?' }],
})
console.log('stop_reason:', response.stop_reason)
// → tool_use
console.log(
'tool call:',
JSON.stringify(
response.content.find((b) => b.type === 'tool_use'),
null,
2
)
)
// → { type: 'tool_use', name: 'get_current_time', input: { timezone: 'Asia/Tokyo' }, id: '...' }
stop_reason: tool_use と input.timezone: "Asia/Tokyo" が返ってくれば成功。ここまで確認してから本番コードに組む。
Step 5:ストリーミングとの組み合わせ
tool_use とストリーミングは相性が悪い。ストリーミング中は tool の引数が全部揃わないため、ループが組めない。
俺が採用したのはハイブリッド方式:tool loop は非ストリーミングで完走させて、最終の text response だけストリーミングする。
// tool loop は非ストリーミング
let response = await client.messages.create({
model: 'claude-opus-4-6',
max_tokens: 1024,
tools,
messages: currentMessages,
})
while (response.stop_reason === 'tool_use') {
// ... tool 実行 + 会話履歴更新 ...
response = await client.messages.create({ ... })
}
// 最終回答だけ stream で返す
const stream = client.messages.stream({
model: 'claude-opus-4-6',
max_tokens: 2048,
messages: [
...currentMessages,
{ role: 'assistant', content: response.content },
],
})
return new Response(
new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
if (
chunk.type === 'content_block_delta' &&
chunk.delta.type === 'text_delta'
) {
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({ text: chunk.delta.text })}\n\n`
)
)
}
}
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
controller.close()
},
}),
{
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
}
)
tool 実行中はフロントに「データ取得中...」のスピナーを出す。実行に数秒かかることがあるので、これがないと UX が死ぬ。
踏んだ地雷まとめ
地雷1:tool_result の content は必ず文字列
content: toolResult と直接渡したら TypeError で落ちた。JSON.stringify(toolResult) が必須。型定義上は string だけど、オブジェクトを渡しても型チェックが通るケースがあって罠。
地雷2:同時呼び出しに非対応だった
Claude は1ターンで複数の tool を並列で呼ぶことがある。for...of でシリアルに実行してたら 2 番目の tool_result が正しく紐づかなかった。Promise.all で並列実行が正解。
地雷3:description を英語で書いた
日本語コンテキストで英語 description を書いたら、Claude が tool を呼ばないケースが増えた。description は会話言語に合わせる。日本語ユーザー向けなら日本語で書く。
地雷4:ループ条件を間違えた
while (response.stop_reason !== 'end_turn') で実装したら、max_tokens 超過や API エラー時に無限ループした。=== 'tool_use' でループするのが正しい。
地雷5:認可をサボった
tool ハンドラは Claude 経由でも直接エンドポイントを叩けるので、認可ロジックはハンドラ側に実装する。description に「管理者のみ使用」と書いても Claude は守らないことがある。
実装自体は27分で終わった。難しくない。難しいのは「どの tool を定義するか」の設計と、「description をどう書くか」の言語設計だ。ここの精度がそのまま Claude の精度になる。
次回は structured output を使った強制 JSON 返却パターンを書く(tool_choice: {type: "tool"} で特定 tool を強制呼び出しして JSON スキーマ通りに返す方法)。これが使えると AI レスポンスのパース地獄から解放される。
俺が運営してるプロダクト
🎬 VideoTracker — 不動産業者向け動画自動生成 SaaS
動画1本¥596。SUUMO 問合せ平均1.8倍。
→ https://komugi-ai.jp/realestate
🤖 Mint Agent — Slack で @AI に話しかけて業務代行(近日リリース)
議事録投稿・メール返信・データ集計が Slack 内で完結
→ ベータ Waitlist:https://agent.komugi-ai.jp
🏭 Forge — 企業向け AI 実装・運用ファーム
構築 → 評価 → 運用まで一気通貫で請け負う
→ https://forge.komugi-ai.jp
業務効率化・SaaS 開発相談 → X DM @mintnekoneko0
過去記事まとめ:https://note.com/mintototo1