はじめに
きっかけはSalesforce Mult-Framework を使用した React による UI 開発(Salesforce Developers Blog, 2026-04)という記事でした。
TDX 2026で発表された Salesforce Multi-Framework を使うと、Developer Edition のSalesforce組織上でReactアプリをネイティブに動かせます。これを触ってみたくて、Developer Edition組織に手始めのReactアプリをいくつか作っていく中で、「どうせなら動くものを作ろう」とオセロを実装しました。
作ったはいいものの、1つの画面を2人で交代しながら使う対戦だと1人では遊べません。対戦相手が欲しくなり、「Agentforceエージェントを作って対戦相手にすればいいのでは」と思いつき、Agent Scriptでオセロアドバイザーエージェントを作って、両者を繋ぐ検証を始めました。最終的には、当初想定していた「チャットで対局する」を通り越して、チャットUIを一切使わずApexから直接エージェントを呼び出して自動対戦するところまでたどり着きました。実際に動いている様子はこちらです。
この記事は、その一連の過程(オセロを作る→エージェントを作る→繋ぐ→チャットだと不便で直接呼び出しに切り替える)の記録です。開発・検証は Developer Edition 組織で行いました。
実装はすべてClaude Codeと一緒に開発しました。 つまずいた箇所やエラーメッセージも脚色せず、実際にやり取りした内容そのまま赤裸々に語ります。
Step 1: React on Salesforceでオセロを作ってみた
Salesforceでは、アプリの画面や設定といった「構成」の情報をメタデータと呼びます。Salesforce Multi-Frameworkもこの仕組みに乗っていて、実体は UIBundle というメタデータの1種類です。sf CLIでプロジェクトにReactアプリのひな形を生成し、Vite + TypeScript + Tailwindで実装を進め、sf project deploy start でデプロイすると、https://<org>--c.<instance>.my.salesforce.app/app/c__<bundle名> という専用ドメインでアプリが動きます。App Launcherから開けるようにするには、UIBundle を参照する CustomApplication と、それを見えるようにする PermissionSet も一緒にデプロイします。
オセロ自体のロジック(合法手判定・石の反転・パス・勝敗判定)はSalesforce固有の話ではない、素朴なReactの状態管理です。
ここまでで、1台の画面を2人で使って交代しながら打つオセロが動くようになりました。デプロイ自体はCLIで完結しますが、盤面が正しく描画されるか、App Launcherで検索したときにちゃんとアプリが見つかるかは、Claude Codeにはブラウザが見えないので実際に画面を開いて自分の目で確認しました。
まずはApp Launcherで、OthelloAppが候補に出てくるかを確認します。
開くと、こんな感じのシンプルな盤面が表示されます。この時点ではAgentforce関連の要素は何もなく、クリックで黒白交互に石を置くだけの2人対戦です。
この時点でできているファイルは次の通りです。
src/
├── applications/OthelloApp.app-meta.xml
├── permissionsets/OthelloApp_Access.permissionset-meta.xml
└── uiBundles/OthelloApp/ ← React本体(Vite+TS)
Step 2: できたけど物足りない — Agentforceを対戦相手にする
盤面はできましたが、対戦相手がいません。友達を呼んでくるわけにもいかないので、「Agentforceエージェントを対戦相手にしよう」と思い立ちました。
Agentforceとは
Agentforce はSalesforceのAIエージェントプラットフォームです。エージェントの作り方は2通りあり、Agentforce Studio(管理画面)でGUIから部品を組み立てる方法と、Agent Script というテキストの言語でスクリプトとして書く方法があります(Agent Script)。今回はテキストで書く方法を使い、以下のブロックでOthello_Advisor.agentを書きました。
-
system: 人格・メッセージ -
config: 開発者名・エージェント種別 -
start_agent: トピックごとの指示とaction定義
今回作ったOthello_Advisorは agent_type: AgentforceEmployeeAgent — 社内向け(Employee向け)のエージェントです。Agentforceには他に、Webサイトや問い合わせ対応で使う顧客向けのService Agent、Slack上で動くエージェントなど、用途別のタイプがありますが、今回はオセロの対戦相手なので社内向けタイプで十分でした。
実際のファイルの中身はこんな感じです。オセロのルール説明・戦略アドバイス・次の一手提案をするよう指示を書いています。
system:
instructions: "You are Othello Advisor, a friendly and knowledgeable
assistant for the classic strategy board game Othello (Reversi)..."
config:
developer_name: "Othello_Advisor"
agent_label: "Othello Advisor"
agent_type: "AgentforceEmployeeAgent"
start_agent othello_advisor:
reasoning:
instructions: |
RULES — Explain how Othello works...
STRATEGY — Corner control, edge stability, mobility,
avoid X/C-squares, parity...
MOVE ADVICE — When the user describes a board situation,
provide concrete practical advice...
この時点のエージェントは、あくまで「聞かれたら答える」チャットボットです。盤面を見て自分から手を選ぶ仕組みはまだ何もありません。オセロの盤面アプリとAgentforceエージェント、2つのパーツが揃ったところで、次はこれを繋ぎ込む番です。
Step 3: まずはチャットで対戦させてみる
盤面とエージェントを繋ぐには、①盤面の状態をエージェントに渡す経路を作る、②エージェントが「見た目のアドバイス」ではなく「合法手の中から実際に選んだ一手」を返せるようにする、の2つが必要です。
この過程で3つのつまずきがありました。以下は影響が大きかった順で、実際に遭遇した順ではありません。
設計方針: 合法性の判定はApex、どの手を選ぶかはエージェントに任せる
LLMに「オセロ盤面のテキストを見せて次の一手を聞く」だけだと、存在しないマスや反則手を平気で提案してくる(ハルシネーション)リスクがあります。なのでここは役割分担にしました。
- 合法手の計算はApexが保証する: 標準的なオセロの反転ルールに従い、置ける場所を機械的に列挙する
- どの手を選ぶか(角優先・X/C-square回避・着手可能数の確保など)はエージェントのreasoningに任せる
public with sharing class OthelloMoveAdvisor {
public class MoveRequest {
@InvocableVariable(required=true label='Board Text'
description='TURN:BLACK/WHITE の行 + 8x8盤面(各行8文字, .=empty B=black W=white)')
public String boardText;
}
public class MoveResult {
@InvocableVariable public Boolean isValid;
@InvocableVariable public String errorMessage;
@InvocableVariable public Boolean mustPass;
@InvocableVariable public String legalMoves; // "C4,D3,F5"
}
@InvocableMethod(label='Get Othello Legal Moves')
public static List<MoveResult> getLegalMoves(List<MoveRequest> requests) {
// TURN:BLACK/WHITE + 8x8グリッドをパースし、8方向走査で合法手を列挙
}
}
盤面はこんな固定フォーマットのテキストでやり取りすることにしました。人間が手で組み立てることはなく、React側で機械的に生成する前提なので、可読性より厳密パース優先です。
TURN:WHITE
........
........
........
...WB...
...BW...
........
........
........
つまずき①: チャット埋め込みSDKには「送る」も「読む」もAPIが無かった
盤面をエージェントに渡すために、まずはUIBundleに @salesforce/agentforce-conversation-client というSDKでAgentforceのチャットウィジェットを埋め込みました。これでReactから盤面を送れるだろうと思っていたのですが、型定義とREADMEを読むと、公開APIは以下だけでした。
export interface EmbedAgentforceClientOptions {
container: string | HTMLElement;
salesforceOrigin?: string;
// ...
onError?: AgentforceErrorHandler;
onReady?: AgentforceReadyHandler;
}
onReady と onError というライフサイクルイベントと、chatClientComponentという生のDOM要素が返るだけで、チャットにメッセージを送り込む・Botの返答を読み取るための公開APIは見当たりませんでした。理由は単純で、このSDKはLightning Out 2.0でチャットUIをiframeとして描画しているだけの薄いラッパーだからです(lo.iframe.error というイベント名が型定義に出てくることからも分かります)。iframeの中身は別ドキュメントなので、親ページのJavaScriptからは触れません。
つまり「Reactが自動で盤面を送り、返答を自動でパースして盤面に反映する」という完全自動連携は、このSDKの範囲では不可能でした。
しかたないので、まずはリレー方式で乗り切ることにしました。Reactに「エージェント用の盤面テキストをコピー」ボタンを置き、ユーザーが手動でチャットに貼り付けて送信し、返ってきた (C, 4) のような座標を読んで盤面をクリックする、という運用です。
最初のバージョン(リレー方式)。オレンジ色の区間が人間の手作業、水色の区間がエージェントの思考。エージェント自身は合法手の中から実際に考えて手を選んでいるが、人間が2箇所で盤面とチャットを橋渡ししている
エージェント自身は実際に合法手の中から考えて手を選んでいるので「対戦」としては成立しますが、体験としては明らかに面倒でした。
つまずき②: Agent Scriptのactions:ブロックとApexのフィールド名を一致させる
ApexのOthelloMoveAdvisorをエージェントから呼べるようにするには、Agent Script側でActionを宣言する必要があります。公式のサンプルを参考に、こう書きました。
start_agent othello_advisor:
actions:
get_legal_moves:
description: "..."
inputs:
board_text: string
description: "..."
is_required: True
outputs:
is_valid: boolean
error_message: string
must_pass: boolean
legal_moves: string
target: "apex://OthelloMoveAdvisor"
reasoning:
instructions: |
...
actions:
get_legal_moves: @actions.get_legal_moves
sf agent validate authoring-bundle はエラー0件で通りました。ところが sf agent publish authoring-bundle を実行すると、こう怒られました。
このエージェントを確定するには、次の検証の問題を解決してください:
「input」の無効な属性値 アクションの必須の input パラメーターがありません: 「boardText」。
原因は単純で、Action定義のinputs/outputsの名前は、紐づくApexクラスの@InvocableVariableフィールド名と完全に一致していないといけないのに、私はAgent Script側だけ慣習的にスネークケース(board_text)で書いていました。Apex側はboardText(キャメルケース)です。sf agent validateのコンパイルチェックはAgent Scriptの構文だけを見ていて、Apexとの整合性まではチェックしてくれないため、ここは実際にpublishするまで気づけませんでした。
board_text → boardText、is_valid → isValid、must_pass → mustPass … とApexのフィールド名にすべて揃えたら通りました。
つまずき③: GenAiPlannerBundleを直接デプロイしてはいけない
Action定義を書く前、実は一度別の詰まり方をしています。エージェントの構成を更新しようとして、素朴にsf project deploy start -m "GenAiPlannerBundle:Othello_Advisor_v1"を実行したところ、こうなりました。
Component Failures [1]
GenAiPlannerBundle Othello_Advisor_v1
An unexpected error occurred. Please include this ErrorId if you contact
support: 211946448-159901 (-1103525358)
GenAiPlannerBundleはBuilder(またはAgent Script publish)が生成する成果物であって、人間が直接いじってデプロイするものではありません。-m "GenAiPlannerBundle:..."を指定した単体デプロイは行わないでください。
正しい手順は次の3コマンドでした。
sf agent validate authoring-bundle --api-name Othello_Advisor --target-org dev-org
sf agent publish authoring-bundle --api-name Othello_Advisor --target-org dev-org
sf agent activate --api-name Othello_Advisor --target-org dev-org
publishが中でAgent Scriptのコンパイル→org反映→Bot/BotVersion/GenAiPlannerBundleの生成・retrieveまで面倒を見てくれるので、GenAiPlannerBundleを単体で触る必要はそもそもありませんでした。ここまで、Apexのデプロイ・Agent Scriptのvalidate/publish/activateはすべてCLI(=Claude Codeとのペアプロ)で完結しています。
Step 4: それでも物足りない — Apexから直接呼び出してみる
リレー方式は動くには動きましたが、対戦のたびに「コピー→チャットに貼る→送信→返答を読む→クリック」を繰り返すのは正直しんどい。チャットUIを経由しない方法を探しました。
ブレイクスルー: Apexから直接エージェントを呼び出せることが分かった
つまずき①で「チャットSDKには送受信APIが無い」と書きましたが、これはあくまでチャットUIを外側から操作しようとした場合の話でした。視点を変えて、チャットUIを経由せずApexから直接エージェントを呼び出す方法を探したところ、Invocable.ActionクラスにcreateCustomActionというメソッドがあり、Botをアクションの一種として直接invokeできることが分かりました。
// 第一引数はどのAgentforceエージェントでも共通の汎用アクション名。
// 第二引数にBotの開発者名を渡すだけで、そのエージェントを直接呼び出せる
Invocable.Action action = Invocable.Action.createCustomAction(
'generateAiAgentResponse', 'Othello_Advisor'
);
// ユーザーのチャット入力と同じ扱いでエージェントに渡る
action.setInvocationParameter('userMessage', boardText);
// エージェントのreasoningがApexと同じスレッドで同期的に走る
List<Invocable.Action.Result> results = action.invoke();
// agentResponse に、チャットUIと同じ最終回答のテキストが入っている
String reply = (String) results[0].getOutputParameters().get('agentResponse');
チャットの画面もセッションも介さず、Apexのメソッド呼び出し1つでエージェントの頭脳部分だけを叩けるというのが、この発見の核心です。
匿名Apexで試したところ、実際にエージェントの応答がテキストで返ってきました。
agentResponse: "The four legal opening moves for black in Othello are:
(C, 4), (D, 3), (E, 6), and (F, 5)..."
しかも Number of callouts: 0 out of 100 — コールアウトのガバナ制限を一切消費しませんでした。OAuthもNamed Credentialも一切不要でした。
実装: チャット無しで完結する自動対戦
OthelloMoveAdvisor(合法手の計算)はそのまま使い、新たにOthelloAgentMoveというApexクラスを追加しました。役割は「合法手を確認する→エージェントを呼ぶ→返答をパースする→合法手リストと照合し、ダメなら先頭の合法手にフォールバックする」です(エラーハンドリング部分は省略した抜粋です)。
private static MoveResult getAgentMoveForOne(MoveRequest request) {
MoveResult result = new MoveResult();
// 1. Apex側だけで合法手を確定する(get_legal_moves Actionは経由しない)
OthelloMoveAdvisor.MoveRequest legalRequest = new OthelloMoveAdvisor.MoveRequest();
legalRequest.boardText = request.boardText;
OthelloMoveAdvisor.MoveResult legal = OthelloMoveAdvisor.getLegalMoves(
new List<OthelloMoveAdvisor.MoveRequest>{ legalRequest }
)[0];
List<String> legalMoves = legal.legalMoves.split(',');
Set<String> legalMoveSet = new Set<String>(legalMoves);
// 2. 合法手リストをプロンプトに埋め込んでエージェントを呼ぶ
Invocable.Action action = Invocable.Action.createCustomAction(AGENT_ACTION_NAME, AGENT_NAME);
action.setInvocationParameter('userMessage', buildPrompt(request.boardText, legal.legalMoves));
Invocable.Action.Result actionResult = action.invoke()[0];
String agentReplyText = extractResponseText(actionResult.getOutputParameters().get('agentResponse'));
// 3. 返答から座標を抽出する。合法手リストに含まれる座標のうち、
// 最後に言及されたものを優先する(理由は後述)
String parsedMove = parseMoveFromText(agentReplyText, legalMoveSet);
Boolean accepted = parsedMove != null && legalMoveSet.contains(parsedMove);
// 4. 不正な手/抽出失敗なら先頭の合法手にフォールバック
result.move = accepted ? parsedMove : legalMoves[0];
result.usedFallback = !accepted;
return result;
}
このフォールバックが地味に重要で、LLMの返答が期待した形式から外れたり、合法手ではない座標を言ってきたりしても、ゲームが止まらずに進行します。座標の抽出も、最初に見つかった座標をそのまま使うと、エージェントが「(D,3)も候補だが(E,6)にする」のように複数の座標を返答に含めた場合に誤った手を拾ってしまうため、合法手リストと照合しながら最後に言及された座標を優先するようにしています。
Reactからは、このOthelloAgentMoveを@RestResourceでApex RESTのエンドポイントとして公開し、@salesforce/platform-sdkの認証済みfetchで叩くようにしました。白番になったタイミングで自動的に呼び出すuseEffectを仕込んだことで、ユーザーは何もしなくてもエージェントが自動で一手を返してくるようになりました。
Invocable.Action.invoke()はエージェントの応答が返るまでApexが同期的に待つ処理です。オセロ程度なら数秒で収まりますが、本番でリクエスト数が増える場合は「長時間実行中の同期リクエスト(5秒超)は組織あたり同時10件まで」という制限に触れる可能性があります。Queueable Apexや非同期処理+ポーリングへの切り替えも検討事項です。また今回のRESTエンドポイントは、クラスへのアクセス権を持つ認証済みユーザーなら誰でも叩けます(=誰でもAgentforce Actionを課金消費できる)。対戦者を絞りたい場合はPermissionSetの割り当て範囲を絞るなどの対策が必要です。
最終的な構成。青い区間がすべて自動化された範囲。チャットUIも人間の手動操作も介在せず、Apexだけで「盤面を渡す→エージェントが選ぶ→反映する」が完結する
結果
「Agentforceと対戦」をONにして黒番(自分)を打つと、白番になった瞬間にエージェントが考えて一手返してくる、というところまで動くようになりました。Apexテストは合法手判定・エージェント呼び出し・REST部分あわせて14本、すべてorg上でPassしています(テスト実行環境からは実際のエージェント呼び出しはできないため、テストが検証しているのはフォールバック経路までです。エージェントが実際に手を選ぶ挙動自体は、匿名Apexとブラウザでの動作確認で確かめています)。
構成の変化をまとめると次の通りです。
| Before(リレー方式) | After(Apex直接呼び出し) | |
|---|---|---|
| 盤面をエージェントに渡す | 人間がコピペ | Apex REST呼び出しで自動 |
| エージェントの返答を読む | 人間がチャットを見る | Apexがレスポンスをそのまま受け取る |
| 盤面への反映 | 人間がクリック | Reactが自動反映 |
| 必要な認証設定 | (チャットのログインセッションのみ) | 追加設定なし(Calloutも消費しない) |
出来上がったファイル構成
今回はカスタムオブジェクトを1つも作らず、すべてメタデータで完結しました。最終的に触ったファイルを整理すると、こうなります。
-
自分で書いたもの:
Othello_Advisor.agent(Agent Script本体)と、Apexクラス3本(OthelloMoveAdvisor.cls/OthelloAgentMove.cls/OthelloAgentMoveResource.cls、それぞれテスト付き) -
sf agent publishが自動生成したもの:bots/とgenAiPlannerBundles/の配下すべて。書いたのは.agentファイル1つなのに、実行系のメタデータ一式がここに出てきます
全体のツリーはこちらです。
src/
├── aiAuthoringBundles/Othello_Advisor/
│ └── Othello_Advisor.agent ← 自分で書いたのはここだけ
├── bots/Othello_Advisor/ ← ここから下、publish時に自動生成
├── genAiPlannerBundles/Othello_Advisor_v2/
│ └── agentGraph/Othello_Advisor_v2_graph.json ← コンパイル後のAgent Graph本体
├── classes/
│ ├── OthelloMoveAdvisor.cls(+Test) ← 合法手判定Action
│ ├── OthelloAgentMove.cls(+Test) ← エージェント呼び出し+フォールバック
│ └── OthelloAgentMoveResource.cls(+Test) ← Apex REST公開用
├── applications/OthelloApp.app-meta.xml
├── permissionsets/OthelloApp_Access.permissionset-meta.xml
└── uiBundles/OthelloApp/ ← React本体(Vite+TS)
特にagentGraph/*.jsonは、Agent Scriptがコンパイルされた実行時グラフの生の中身です。普段は意識しませんが、「Agent Scriptは最終的にこの状態機械にコンパイルされている」ことが実感できて面白いです。
まとめ
なお今回はApexから直接呼び出す経路を選びましたが、Salesforceは会話型のエージェントAPIとして/einstein/ai-agent/v1/というREST APIも公式に提供しています。ApexやSalesforce外部から会話セッションを扱いたい場合はそちらも選択肢になるはずなので、あわせて調べてみると良いかもしれません。
Agent Scriptと Apex Invocable Action の組み合わせは、まだ日本語の実例が少ない領域でした。同じようにAgentforceを自分のアプリに組み込もうとしている方の参考になれば幸いです。


