1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

16:42 書き始め、17:09 動いた:Claude API の tool use を Next.js に完全実装する

1
Posted at

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_useinput.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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?