0
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?

Claude Code SDK で社内エージェントを 2 週間で構築 — TypeScript 実装と運用知見

0
Posted at

連載開始にあたって

これからしばらく、Production で実際に動かしている AI システムの実装記録を Qiita に残していこうと思う。設計の話だけ、ライブラリの API 解説だけ、というのは既に良い記事が大量にあるので、筆者は「2 週目に何が壊れたか」「最初に踏んだ罠は何か」みたいな運用フェーズの粒度で書きたい。

初回は、社内向けの小さなエージェントを TypeScript で 2 週間で立ち上げた話。一つ断っておくと、「Claude Code SDK」は 2025 年 9 月に「Claude Agent SDK」へリネームされている。npm パッケージは @anthropic-ai/claude-agent-sdk。タイトルは検索流入のために旧名を残したが、本文のコードはすべて現行 SDK 前提だ。

TypeScript で書かれた社内 AI エージェントの開発環境

何を作ったか

社内 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.isErrorstop.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 エージェントの開発を担当している。

0
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
0
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?