AIエージェントが複数の工程を自律的にこなし、要所で人間が承認をする——そういう「エージェンティックなシステム」を、業務で使える形にどう組み上げるか?、その実装パターンはまだあまり共有されていません。ワークフローをどう定義し、どこで人の操作を挟み、状態をどう永続化し、外部への出力をどう守るか。
この記事は、その一つの具体的な解答です。この記事では、実際に動いているコードとして、エージェンティックシステムのひとつの実装パターンを残します。ここで作ったシステムはすでに約80%が完成し、現在は完成に向けて部分的な作り込みをしている段階です。
以下、実際のコードとプロンプトを参照しながら、このシステムの開発を振り返ります。

なお、このシステムのコンセプトや設計について詳しくは、こちらの記事をお読みください。

技術スタック
まずは今回使用した技術スタックを簡単に紹介します。
- フロント:Next.js(App Router / TypeScript)
- 実務・エージェント:FastAPI + LangGraph
- データ:Supabase(PostgreSQL + pgvector
- LLM:Claude API(テキスト+Vision=FAX画像の読み取り)
- 文字起こし:faster-whisper
- ワークフロー可視化:React Flow(@xyflow/react)
- 帳票:Jinja2 → PDF(Playwright / Chromium)
- 受信:IMAP ポーラー(メール)/見積の材料に過去案件の類似検索(RAG)
- デプロイ:Railway
- 開発ツール:Claude Code
開発の進め方 ── Claude Code と組む二つのモード
まずは開発の進め方についてお話したいと思います。
前半は、Claudeと設計を議論し、生成されたコードを自分でページごとにコピー&ペーストして組み立てるやり方で進めました。自分で手を動かす分だけ、時間が掛かりますがどこに何が入っているかは把握しやすいやり方です。
今回はAgent側の実装はPythonでおこないましたが、自分はWeb畑出身でPythonでの開発経験はそれほどないため、最初はこのやり方を選びました。
後半は、Claude Code にプロジェクトへのアクセス権を渡し、コードを直接編集させるやり方に切り替えました。これは痺れを切らしたCaludeの方から「プロジェクトフォルダへのアクセス権をもらえれば私が書きます」と打診があったので、与えました。
完全にコーダーとしての役割をClaudeに委ね、自分は設計者(もしくはプロマネ?)になりきるというアプローチです。Claudeをコードをいじっている間は自分は他の作業をします。このやり方ならば自分の時間のロスは最小限に減らせます。しかし、自分のプロジェクト把握の解像度は確実に下がります。正直なところClaudeが書いたパートは他の人にうまく説明する自信が無いです。
対策として私は、区切りごとに変更サマリーを残し、コミットを読み返し、こうして開発記録を書くこと自体を「把握を取り戻す作業」に充てています。速度は上げていい。ただし、把握を手放さない仕組みを、同じ速度で回す——これが後半でいちばん重い学びでした。
| モード | 議論+手コピペ | Claude Code で直接編集 |
|---|---|---|
| 進め方 | Claudeと設計を対話し、生成コードをページ単位で自分で貼る | プロジェクトにアクセス権を渡し、複数ファイルを直接編集させる |
| メリット | プロジェクトを隅々まで把握できる/作業が自分の知識に結び付く/変更が小さく安全 | 任せている間に他の作業ができる/多ファイルの変更・リファクタが一気に進む/大規模でも最速 |
| デメリット | 自分のコピペ速度が上限/多ファイルの変更は遅い/コーダーとしての学習効果は低い | 何を変えたか把握しづらい/テストを怠ると重大なズレを見逃す/コーダーとしての学習効果はほぼ無い |
| 向いている場面 | 立ち上げ期・設計を固める段階・初見の技術を学びながら | 設計が固まった後の量産・拡張・リファクタ・機能追加 |
いずれの方法を取るにせよ、Claudeにコードを書かせる場合は、技術スタックを学習する時間を別に確保するようにしたほうがいいでしょう。
環境構築 ── ローカルで最小構成を最短で動かす
プロジェクトのレポジトリはモノレポで、2つのアプリが入っています。apps/agent(Python:FastAPI+LangGraph)と apps/web(Next.js)です。
開発環境は VSCode でおこないます。特別な拡張や設定はほとんどせずに、素のままで使いました。まずモノレポのルートになるフォルダを手作業で作り、その中apps/web(Next.js)は npx create-next-app@latest apps/web でプロジェクトを生成しました。
データの土台は Supabase です。ダッシュボードでプロジェクトを手動で作成し、テーブルは Claude に生成させた SQL(schema.sql) を SQL Editor に貼って流します。これ一枚で pgvector 拡張ごとDBが丸ごと立ち、あとはダミーの seed を入れれば、外部サービスをつながなくても端から端まで動かせます。
実務側(Python)は、仮想環境を作ってパッケージをインストールします。起動時(FastAPI の lifespan)に、コンパイル済みグラフの用意・メール受信の常駐ポーラー・埋め込みのウォームアップが一度に走ります。
# 0) プロジェクトのルートを手で作成 → 画面の雛形を自動生成
mkdir synaptiq && cd synaptiq
npx create-next-app@latest apps/web # Next.js(TypeScript)の雛形を自動生成
# 1) Supabase: ダッシュボードで手動作成 → Claude が生成した schema.sql を SQL Editor で流す
# 2) 実務(Python / :8000)
cd apps/agent
python -m venv .venv
. .venv/bin/activate # macOS/Linux
# .\.venv\Scripts\Activate.ps1 # Windows(PowerShell)
pip install -r requirements.txt
playwright install chromium # PDF生成用(pip とは別に要る)
uvicorn app.main:app --reload # 起動時に graph warm + IMAPポーラー + 埋め込み warm
# 3) 画面(Next.js / :3000)
cd apps/web && npm run dev # AGENT_URL 経由で実務側へ転送(追加ライブラリは都度 npm i)鍵と接続先は .env にまとめます。.gitignore に.envを含めるのを忘れないようにしましょう!
ANTHROPIC_API_KEY=...
CLAUDE_MODEL=claude-sonnet-4-6
SUPABASE_URL=...
SUPABASE_SERVICE_KEY=sb_secret_...データベースの準備|Supabase
エージェンティックシステムの中心はデータ(Supabase)です。
まず、スキーマは一枚の schema.sql に集約しています。まっさらな Supabase にこの一枚を流せばDBが丸ごと再現できます。初回納品後にデータ構造を変える場合はこのファイルを編集せず、migrations/NNNN_*.sql を追記していくという運用になっています。

設計判断の多くは、スキーマにそのまま書き込みました。たとえば「ロール(権限)を持たない」という方針は、テーブルのコメントとして残っています。
-- ロール(権限)は持たない。あるのは認証だけ(Roleless Access)。
create table employees (
id uuid primary key default gen_random_uuid(),
full_name text not null,
display_name text not null,
auth_id uuid -- Supabase auth への紐づけ(将来)
);見積の材料になる過去案件(past_jobs)には、意味検索用の埋め込みを同じ行に同居させました。専用のベクタDBを立てないのは、値付けの根拠と検査の根拠が、いつか別のシステムで食い違うのを避けるためです。
create extension if not exists vector; -- 専用ベクタDBは立てない
create table past_jobs (
id uuid primary key default gen_random_uuid(),
drawing_number text,
shape_features text,
material text,
actual_unit_price numeric,
actual_work_hours numeric, -- 1個あたり。大ロットほど学習効果で下がる
mass_kg numeric, -- 図面の物理的性質。同じ図番なら常に同じ
notes text, -- 「特急対応」等。乖離が正当かを検査役が読む
embedding vector(384) -- multilingual-e5-small をローカルで実行して埋める
);状態や制御も、データに持たせています。工程ごとの運用モード(auto/approval/manual)や緊急停止は mode_settings の行に、全操作は audit_logs に。承認・修正・モード変更・停止/再開が、すべて一箇所に事後の証跡として残ります。さらに見積りテーブルには、値付けエージェントの思考の軌跡まで残しました。
-- quotes 抜粋:generation_basis が「何を見たか」、reasoning_trace が「どう考えたか」
generation_basis jsonb, -- 参照した past_jobs の id 群(判断の監査証跡)
reasoning_trace jsonb, -- ツール呼び出しの引数・戻り値・消費予算・停止理由。値段を再生できる
inspector_flags jsonb not null default '[]'::jsonb -- 検査役のフラグ(承認後・送信直前に算出)「何を見て、どう考え、いくらにしたか」が後から再生できる——これは事業承継で判断を引き継ぐときの、いちばんの資産になります。
LLMラッパー ── Claudeをどう呼ぶか?
実務側からClaudeを呼ぶ窓口は、一枚のラッパークラス(app/lib/claude.py)に集約しました。全ノードがここを通ります。狙いは3つ。モデルを差し替え可能にする、呼び出しを計測する、LLMの出力を構造化データとして受け取ることです。
クライアントは遅延生成(lru_cache)で、.env が読まれた後に一度だけ作ります。モデル名は環境変数から取り、既定は claude-sonnet-4-6。ここを一箇所にしておくと、モデルの載せ替えが env の一行で済みます。
一発補完の complete() は、呼び出し元ノードの label とともに、入出力サイズ・所要時間・トークン使用量をログに出します。ただしプロンプト全文は出しません——プロンプトはその会社固有の判断ロジックだからです。
def complete(system: str, user: str, max_tokens: int = 2000, label: str = "?") -> str:
# label は呼び出し元ノード名(例 'structure_order')。ログに用途を残す。
# プロンプト全文はログに出さない(Codified Judgment のため)。
msg = _client().messages.create(
model=_model(), max_tokens=max_tokens, system=system,
messages=[{"role": "user", "content": user}],
)
out = "".join(b.text for b in msg.content if b.type == "text")
usage = getattr(msg, "usage", None)
print(f"[CLAUDE] {label} | ok | out={len(out)} chars | "
f"tokens in/out={usage.input_tokens}/{usage.output_tokens}")
return outFAXチャネル用に、画像やPDFをbase64のcontent blockに載せて渡す complete_with_media() も同じラッパーに置きました。これでOCRエンジンを別に立てず、Claudeが注文書の画像を直接読みこみます。
# 画像/PDF を base64 の content block にして渡す(FAX を Claude Vision で直読)
kind = "document" if media_type == "application/pdf" else "image"
content = [{"type": kind, "source": {"type": "base64",
"media_type": media_type, "data": b64}}]地味だが効くのが extract_json() です。LLMの返答は、コードフェンスや前後の散文が混じることがあります。それを許容して最初のJSONオブジェクトだけを取り出す。LLMの自由な出力を、確実に構造化データへと変換するために関所です。
def extract_json(text: str) -> dict:
cleaned = re.sub(r"^```(?:json)?\s*", "", text.strip())
cleaned = re.sub(r"\s*```$", "", cleaned)
try:
return json.loads(cleaned) # まず素直にパース
except json.JSONDecodeError:
start = cleaned.find("{") # だめなら最初の { から raw_decode
obj, _ = json.JSONDecoder().raw_decode(cleaned[start:])
return objこのほか、値付けエージェントのようにツール使用ループを回す呼び出し元には、messages API を直接叩ける raw_client() も用意しています。窓口を一つにしておくと、モデル差し替え・ログ・リトライを、すべてのノードに対して1箇所から反映できます。
プロンプト=判断ロジックを自然言語で書く
このシステムで「アルゴリズム」に相当するものは、ワークフローとプロンプトと参照データの3か所に分散しています。なかでもプロンプトは、その会社固有の判断を自然言語で書き起こしたものです。ここでは重要な部分を、出来る範囲で見せます。
入口の構造化は、構造化されていないテキストを厳格なスキーマへと落とすプロンプトです。ポイントは「AIに推測させない」ことに尽きます。
# structure_order(抜粋)
Return **JSON only**. Extract every distinct line item.
- unit_price は顧客が明示したときだけ入れる。空欄は「要見積」— 数字を発明しない。
- 図番・材質は誤読しやすい。怪しければ抽出はするが confidence を下げる。
- 電話の文字起こしは confidence を下げる(音声の数値・品名は誤りやすい)。
- shape_features は形状と主要寸法("flange, phi120 x t30, 6 bolt holes")。
寸法が無ければ null。図番や品名から寸法を推測しない — 発明した寸法は発明した価格。FAXの読み取り(Claude Vision)も同じ考えで、読めない文字に関しては ? として、どれだけ読めたかを legibility として報告させます。
# read_fax(抜粋・原文は日本語)
かすれ・傾き・ノイズを含む。**推測で補完してはいけない。読めない文字は ? に。**
図番・材質・数量は最も誤読しやすく実害が大きい。少しでも迷えば legibility を下げる。
出力は {"text": "...", "legibility": 0.0} のJSONのみ。ClaudeVision
Claudeが画像を入力として受け取り、内容を理解できるようにする機能です。手書き交じり・多少のかすれ・非定型レイアウトでも、文脈で拾えます。ただし、万能ではなく、かすれがひどい数字は誤読することがあります。
次は見積りにおける値付けパートを紹介します。
# generate_quote(抜粋)
You do not decide the price. You estimate two physical quantities —
the part's mass (kg) and the machining hours per piece — and the company
rate card turns them into a price. Your job is estimation and evidence.
- まず get_drawing_history を必ず呼ぶ。同一図番の実績があれば質量は測定事実。
- submit_quote を呼ぶことだけが完了。黙って止まれば価格は出ず、明細は空欄のまま
検査役が書類を止める。それは意図した失敗 — 自信ありげな誤った数字よりずっとよい。「AIは価格を決めない。質量と工数を見積もり、レートカードが価格にする」——この分業が、値付けを説明可能で監査可能なものにします。最後の検査役は、書類を作った本人ではない独立の立場で、社外に出る直前にその内容をチェックします。
# inspect_egress(抜粋)
You are an independent egress inspector. You did NOT create the document.
乖離が JUSTIFIED(特急・初回試作・documented rework 等で説明できる)か
SUSPICIOUS(桁ずれ等、データで説明できない)かを判定。
根拠が無ければ suspicious に寄せる — 最終判断は人。誤警報は静かな誤りより安い。プロンプトを「指示文」ではなく「判断の記述」として書く。これが、汎用のAIチャットと会社の業務システムのプロンプトの違いです。
ワークフローノードを繋ぐ|LangGraph
次にLangGraphのノード設計について書きます。
ここは読者がいちばん知りたい部分だと思うので、詳しく書きます。実務の背骨は、LangGraph の一枚の定義(app/graph/build.py)に集約されています。まずは全体の流れをご覧ください。
START -> structure_order -> split_lines
-> order_gate ① [interrupt]
差戻し -> END
承認 -> order_egress
-> order_egress [検査役 / 高深刻度のときだけ interrupt]
異常なし / 強行送信 -> deliver_order(受注書送信・確定のみなら生産起動)
差し戻し -> order_gate ①(ループ)
-> deliver_order
要見積あり -> enrich_material -> generate_quote -> quote_gate ②
確定のみ -> END
-> quote_gate ② [interrupt]
差戻し -> END
承認 -> quote_egress
-> quote_egress [検査役 / 高深刻度のときだけ interrupt]
異常なし / 強行送信 -> deliver_quote(見積書送信 → 生産起動)
差し戻し -> quote_gate ②(ループ)
-> deliver_quote -> ENDポイントは2つです。ひとつ目は、社外への送出は deliver_* だけが行う。検査を迂回して外へ出る経路は、グラフ上に存在しません。ふたつ目は、検査役(*_egress)は人の承認の後ろに立つ。これは誤って承認された間違いを含んだ書類を止めるための救済装置です。
ノードの登録は素直です。分岐は「状態を見て次のノード名を返す」ルーティング関数として書きます。
def _route_after_order_gate(state: OrderState) -> str:
"""ゲート①の直後。承認されたものだけが検査役の前に立つ。"""
decision = state.get("decision") or {}
if not decision.get("approved"):
return END # ゲート内で Rejected 済み
return "order_egress"
def _route_after_order_egress(state: OrderState) -> str:
"""異常なし/強行送信 -> deliver、差し戻し -> ゲートへループ。"""
eg = state.get("order_egress")
if eg is None:
return END
if not eg.get("blocked"):
return "deliver_order"
return "deliver_order" if eg.get("override") else "order_gate"
def _route_after_deliver_order(state: OrderState) -> str:
"""受注書を送った後。要見積の明細があれば値付けへ、無ければ完了。"""
lines = state.get("lines", [])
if any(ln.get("line_type") == "needs_quote" for ln in lines):
return "enrich_material"
return ENDそして、これらを add_conditional_edges で結線します。
g = StateGraph(OrderState)
for name, fn in [("structure_order", structure_order), ("split_lines", split_lines),
("order_gate", order_gate), ("order_egress", order_egress),
("deliver_order", deliver_order), ("enrich_material", enrich_material),
("generate_quote", generate_quote), ("quote_gate", quote_gate),
("quote_egress", quote_egress), ("deliver_quote", deliver_quote)]:
g.add_node(name, fn)
g.add_edge(START, "structure_order")
g.add_edge("structure_order", "split_lines")
g.add_edge("split_lines", "order_gate")
g.add_conditional_edges("order_gate", _route_after_order_gate,
{"order_egress": "order_egress", END: END})
g.add_conditional_edges("order_egress", _route_after_order_egress,
{"deliver_order": "deliver_order", "order_gate": "order_gate", END: END})
g.add_conditional_edges("deliver_order", _route_after_deliver_order,
{"enrich_material": "enrich_material", END: END})
# enrich -> generate -> quote_gate②、以降は order 側と対称
return g.compile(checkpointer=get_checkpointer())compile(checkpointer=...) が効いています。interrupt() で止まったとき状態が永続化されるので、サーバが再起動しても、承認作業が翌日になっても、続きから再開できます。「止めて待つ」処理を業務システムとして成立たせているのはこの一行です。

背骨さえ安定していれば、入口はあとから足せます。実際、チャネルは最後にまとめて増設しました。FAX(Claude Vision)・電話(Whisper)・メールが一つの run_intake() に合流し、入口が不確かなほど信頼度の天井を下げます。
# 入口が不確かなほど信頼度の天井を下げる(電話 < FAX < メール)
_CHANNEL_MAX_CONFIDENCE = {"email": 1.0, "fax": 0.9, "phone": 0.8}
def _final_confidence(channel, structured, input_conf):
known = [v for v in (structured, input_conf) if v is not None]
base = min(known) if known else 0.5 # いちばん悲観的な値を採用
return round(min(base, _CHANNEL_MAX_CONFIDENCE.get(channel, 1.0)), 3)フロントエンド ── 承認のためのUI
エージェンティックシステムは基本的にヘッドレスなので、画面は「判断・承認・確認の瞬間」だけに絞っています。
土台は Next.js(App Router)+ next-intl(cookieベースの日英)で、データ取得は薄いプロキシ経由で実務側へ渡します。受注承認画面は、承認待ちと保留を並べ、人がやれるのは承認・限定修正・差し戻しの3つだけです。
App Router・多言語・デザイントークン・タブ名(generateMetadata)といったフロントの土台と、現場ボード(React Flow)の実装は、「現場ボード」の別の記事にまとめます。
つまずいて設計を組み替えた場面
最後に今回の開発で躓いた点をまとめます。
グラフの配線ミス
今回の開発でもっとも重いと感じたのはグラフの配線ミスです。メール送信の際に通る外部送出ゲートが、本来あるべき「承認の後ろ」ではなく「前」に繋がっていた。という重大なミスに、ぎりぎりまで気づきませんでした。
承認の前に検査しても、人が単価を直した後の“実際に社外へ出る数字”は検査されない。それでも動きはするので、テストは通ってしまう。Claude Code が複数ファイルを速く書き換えるほど、こういう「動くけれど意味が違う」ズレは埋もれてしまいます。
Claude Codeはわかりづらい日本語で何を変更したかを報告してきます。すべての報告を100%理解する必要はありませんが、開発が間違った方向に進んでいないかだけは把握できているようにしなければなりません。
メール読み込みが止まらなくなる
今回、もっとも派手に失敗したのは、メール受信をつないだ瞬間でした。
受信箱に前からあった数1000通のメールが、片っ端から受注として取り込まれていったのです。しかもこのシステムは受注書や見積書をメールで送るので、その控えが受信箱に戻ると、それをまた新しい注文と誤認して取り込む——受注が自己増殖しました。
対策は3つあります。
- 処理の前に Message-ID で受信台帳に印を付けて二重取込を止める。
- 自分(
SMTP_FROM)が送信したメールは読まない。 - そして受注取込用の受信箱を送信用と分ける。
「動いた」の裏で静かに事故るパターンです。これに伴い入口の設計をやり直しました。
Pythonコマンド
地味に手こずったのは、Pythonまわりのコマンドです。
仮想環境の作り方、pip install に --break-system-packages が要る場面、uvicorn app.main:app での起動、モジュールの import 解決——普段の言語と勝手が違い、入力するだけで手こずりました。当然、エラーの読み解きにも時間を取られました。
Claude Code に「なぜこのコマンドなのか?」をその都度説明してもらい、言われたとおりにやるだけは終わらせないようにします。
リファクタリングも忘れずにする
機能が増えてプロジェクトが汚くなってきたら、自発的にリファクタリングもしましょう。肥大した main.py はドメイン別ルーターに割り、グラフはグローバル変数をやめて取得関数に集約しました。
@lru_cache(maxsize=1)
def get_graph():
return build_graph() # コンパイル済みグラフは初回1回だけ
for _m in (orders, quotes, dashboard, admin, wall, production, documents, channels):
app.include_router(_m.router)まとめ
今回、この開発で得たいちばん大きな学びは、技術選定でも実装の順番でもなく、Claude Code をプロジェクトに直接つないで開発することの便利さと危うさでした。
つないでしまえば、大きなプロジェクトでも最速で開発が進みます。数往復の会話で、複数ファイルにまたがる変更を代行してもらえる。以前では考えられなかったスピード、開発が進みます。
けれど、その速度と引き換えに、自分が何を変えたのかを把握できなくなります。検査ゲートの配線ミスにぎりぎりまで気づかなかったのも、根っこは同じです。動いてはいるが意味が違う、といったズレは、開発がスムーズなほど埋もれます。
そしてもう一つ、確認できたことがあります。それは設計図さえしっかりしていれば、予備知識がそれほどない分野でも、爆速で、しかもクオリティの高いアプリが作れるということです。
私は製造業の実務にも Python にもそれほど詳しくありませんでした。それでも、思想と設計——何を作り、どこで止め、どう安全に倒すか——が固まってさえいれば、実装の速度と質は Claude Code が引き受けてくれる。逆に言えば、設計図の解像度が、そのままアウトプットの上限になります。
結論はシンプルです。速さは受け取っていい。ただし、入った変更を常にチェックし、自分の言葉で理解し直すプロセスを、開発と同じ速度で回し続けること。 変更サマリーを残す、コミットを読み返す、グラフやスキーマの要所は必ず自分の目で追う——この地味な確認こそが、Claude Code と組んで最速で作るための、唯一の安全装置です。
【中部地方】製造業向けAI自動化ツール 実地検証パートナー募集
株式会社ニューロシンクでは、製造業のバックオフィス業務を自動化するツール「Synaptiq」の開発に伴い、実際の現場で効果を検証していただける企業様を募集しています。 受注・見積もり・請求書の作成から工程管理までをシステムが自動処理し、人間は「内容の承認」を行うだけの環境を目指す取り組みです。
現在は本格提供前の開発段階のため、導入の義務は一切ございません。まずは貴社の業務課題をお伺いし、どこから自動化できるかを無料で一緒に整理させていただきます。
- 対象エリア: 名古屋駅から2時間圏内の製造業様
- ご協力内容: 現行業務のヒアリングと検証(費用負担・導入義務なし)


コメント