連載開始にあたって
これからしばらく、Production で実際に動かしている AI システムの実装記録を Qiita に残していこうと思う。設計の話だけ、ライブラリの API 解説だけ、というのは既に良い記事が大量にあるので、筆者は「2 週目に何が壊れたか」「最初に踏んだ罠は何か」みたいな運用フェーズの粒度で書きたい。
初回は、社内向けの小さなエージェントを TypeScript で 2 週間で立ち上げた話。一つ断っておくと、「Claude Code SDK」は 2025 年 9 月に「Claude Agent SDK」へリネームされている。npm パッケージは @anthropic-ai/claude-agent-sdk。タイトルは検索流入のために旧名を残したが、本文のコードはすべて現行 SDK 前提だ。

何を作ったか
社内 Slack から呼べる「ドキュメント横断検索 + 簡易レポート生成」エージェント。Notion、社内 GitLab、社内 Wiki を MCP 経由で繋ぎ、質問に根拠リンク付きで回答する。先月、チームの開発リード (LLM プロダクトを 3 つは触ってきた人だ) が会議室の whiteboard に「2 週間で Production 突っ込めないなら一旦保留」と書いた、あの図がスタート地点だった。
「2 週間」というのはデプロイして社内 30 人が触り始めるまでを含む期限である。3 割ほど引いて読んでもらえばちょうどいい数字だ。
環境
Node.js 20.x / TypeScript 5.4
@anthropic-ai/claude-agent-sdk(2026-06 時点の最新)MCP サーバ: 3 つ (Notion / GitLab / 社内 Wiki)
モデル: Claude Sonnet 4.6 (デフォルト) / Opus 4.7 (重い要約のみ)
セッションは await using で閉じる
V2 Preview は TypeScript 5.2 以降の await using 構文をサポートしている。エージェントセッションは内部で MCP コネクション、tool registry、対話履歴をすべて抱えるので、明示的に閉じないとプロセスが残るタイプの「忘れがちなリソース」だ。最初は手で session.close() を呼んでいたが、2 日目にテストで例外が出た瞬間に閉じ忘れが起きたので、即 await using に切り替えた。
import { Agent } from "@anthropic-ai/claude-agent-sdk";
import { tools } from "./tools";
import { connectInternalMcp } from "./mcp";
export async function runQuery(question: string): Promise<string> {
const mcp = await connectInternalMcp();
await using session = await Agent.create({
model: "claude-sonnet-4-6",
systemPrompt: SYSTEM_PROMPT,
tools,
mcpServers: [mcp],
maxSteps: 8,
});
let answer = "";
for await (const event of session.run(question)) {
if (event.type === "text_delta") answer += event.delta;
}
return answer;
}
たった 15 行だが、これだけで「ツール呼び出し→結果→次の判断→…」を SDK が回してくれる。maxSteps: 8 は初日のヒヤリハットから来ている。ある社員が「先月までの障害履歴を全部要約して」と聞いた時、ループが 30 step 近く走り続けて Anthropic 側でレート制限に当たった。8 step あれば社内の質問の 95% は十分だった。
Tool は Zod スキーマで定義する
SDK は tool の入力スキーマを Zod で書ける。JSON Schema を手書きする時代に戻る理由はもうない。ハンドラは z.infer で型がそのまま降りてくる。
import { z } from "zod";
import { defineTool } from "@anthropic-ai/claude-agent-sdk";
import { searchInternal } from "./search";
export const searchKnowledge = defineTool({
name: "search_knowledge",
description:
"社内ナレッジ (Notion / Wiki / GitLab issue) を横断検索する。",
inputSchema: z.object({
query: z.string().describe("自然文の検索クエリ"),
sources: z
.array(z.enum(["notion", "wiki", "gitlab"]))
.default(["notion", "wiki", "gitlab"]),
limit: z.number().int().min(1).max(20).default(5),
}),
handler: async ({ query, sources, limit }) => {
const hits = await searchInternal({ query, sources, limit });
return {
content: hits.map((h) => ({
title: h.title,
url: h.url,
excerpt: h.excerpt,
})),
};
},
});
細かい話だが、トップレベルの description は LLM 向けの「この tool を選ぶかどうかの判断材料」、z.string().describe(...) の方は「引数の埋め方の説明」という棲み分けがある。これを混ぜると tool 選択ミスが目に見えて増える。社内のレビューで指摘されてようやく気づいた話だ。
MCP は Streamable HTTP を素直に使う
2025-03 の MCP spec で導入された Streamable HTTP は、もう「新しいトランスポート」ではない。2026 年現在は標準だと思っていい。stdio 経由のローカル MCP を選ぶ理由は、プロセス境界を本当に分けたいときだけになった。
import { connectMcp } from "@anthropic-ai/claude-agent-sdk";
export async function connectInternalMcp() {
return connectMcp({
name: "internal-knowledge",
transport: {
type: "streamable-http",
url: process.env.INTERNAL_MCP_URL!,
headers: {
authorization: Bearer ${process.env.INTERNAL_MCP_TOKEN!},
},
},
});
}
低レイテンシ用途では in-process tool もある。MCP サーバを別プロセスで立てず、TypeScript のコード内で直接 tool を定義する形式だ。社内検索の一部はもともと内製ライブラリで完結するので、わざわざ HTTP に通す意味はなく in-process に倒した。p95 が 180ms → 60ms。「3 倍速い」というより、「3 倍速いと、ユーザは『考えて答えた』ではなく『すぐ返した』と感じる」のが大きい。
4 種のストリーミングイベントを正しく分岐する
session.run() は async iterable を返す。型は discriminated union で、最低 4 種類のイベントを意識する必要がある。型が効くので switch を網羅させれば取りこぼしはない。
for await (const event of session.run(question)) {
switch (event.type) {
case "text_delta":
// ユーザに見せる部分。Slack thread にストリーミング post。
stream.write(event.delta);
break;
case "tool_use":
// どの tool を何で呼んだか。観測用。
logger.info({ tool: event.name, input: event.input });
break;
case "tool_result":
// tool の戻りはここに来る。エラーも tool_result の isError で来る点に注意。
if (event.isError) logger.warn({ toolError: event.content });
break;
case "stop":
// 停止理由 (end_turn / max_steps / tool_error / refusal)
logger.info({ stopReason: event.reason });
break;
}
}
最初は text_delta だけ拾えば動くと思っていた。実際にはエラーハンドリングは tool_result.isError と stop.reason の両方を見ないと「沈黙して終わるエージェント」が生まれる。これに気づいたのは Production 投入 3 日目、CTO から「さっきの質問、3 分待っても何も返ってこなかった」と Slack DM が来た時だ。max_steps で止まっていたのに、ユーザには何も表示されていなかった。
運用 2 週間で見えた数字
30 人 / 14 日間 / 約 1,200 query。データだけ並べる。
1 query あたりの平均トークン: input 18k / output 1.4k
p50 レイテンシ: 2.1s、p95: 6.8s
tool 呼び出し平均: 2.3 回 / query
「答えにならなかった」率: 約 9% (社内アンケート)
9% を「1 割」と聞くと小さく感じるが、100 件中 9 件は誰かが「もう一度自分で検索した」ことを意味する。この数字は連載の後の回のテーマになる予定だ。
2026-06-15 からの課金分離
運用上の話を一つ。2026-06-15 から、Claude Agent SDK と Claude Code GitHub Actions の使用量はインタラクティブな Claude Code とは別に計量されるようになった。エージェント業務はトークン単価ベースに移ったため、社内エージェントを動かしているチームは Anthropic Console の usage を一度確認しておいた方がいい。筆者の実測 (前述の 1,200 query) ベースだと、想定より 1.4 倍ほど月コストが乗った。先に試算しておけば説明コストはゼロで済むのに、後から気づくと面倒だ。
応用の方向
sub-agent パターン: 「検索する子」と「要約する子」を分け、検索側は安いモデル、要約側は Opus に分担する
Memory tool で「過去にチームが何を聞いたか」を持たせ、用語の社内方言を吸わせる
hooks で社内 PII フィルタを差し込む (出力前に正規表現で電話番号·個人名をマスク)
まとめ
Claude Agent SDK は、TypeScript の型システム · Zod · async iterable をフルに前提に置いた設計になっていて、「2 週間で Production」が成立する程度には素直だった。踏んだ罠は「tool description の棲み分け」「stop.reason の見落とし」「課金分離」の 3 つ。次回は、同じ社内エージェントを n8n と Notion 側に拡張したマルチベンダー Gateway パターンの実装について書く予定だ。
筆者は 5years+ で AI エージェントの開発を担当している。