「LWRサイト × Agentforce」 不動産ポータル開発連載、第4回です。 前回(Vol.3)までに、検索・詳細・予約という一連のフローを実装しました。
今回は、いよいよ本サイトのメインコンセプトである 「AIコンシェルジュ (Agentforce)」 の統合です。 「海が見えるファミリー向けの3LDKを探して」と入力するだけで、AIが条件を解釈し、おすすめ物件を提案してくれる。そんな次世代のUXを実装します。
しかし、開発中に最大の 「壁」 にぶつかりました。それは 「サイトログインユーザー(外部ユーザー)は、標準のAgent APIを呼び出せない」 というライセンス/権限の制約です。 今回はその壁を突破したアーキテクチャと、LWC側の実装を公開します。
今回の記事で作成したコンポーネントの全ソースコードをGitHubで公開しています。 ぜひ Clone して、あなたの組織で動かしてみてください。
完成イメージ
ヒーローエリア(トップ画像)の中に検索バーがあり、自然言語で検索できます。 AIからの回答(左)と、抽出された物件カード(右)が並んで表示されます。

「権限の壁」を突破するループバック接続
通常、LWCからApex経由で ConnectApi (Connect in Apex) を呼ぶと、その処理は「ログインユーザー(パートナー/カスタマー)」の権限で実行されます。しかし、現時点(Winter ’26)ではパートナー/カスタマーライセンスにAgent実行権限を付与することが難しいケースがあります。
そこで採用したのが 「指定ログイン情報を使ったループバック接続」 です。
- 統合ユーザーを用意: Agentを実行できる権限(システム管理者など)を持つ内部ユーザーを用意。
- 接続アプリ & 指定ログイン情報: この内部ユーザーとしてSalesforce自身のAPI (
/services/data/vXX.0/connect/...) をコールする設定を作成。 - Apex:
ConnectApiクラスではなく、HttpRequestでこの指定ログイン情報を使ってAPIを叩く。
これにより、「画面操作はサイトユーザーだが、裏側のAI実行は特権ユーザーが行う」 ことが可能になります。
実装ステップ
- REST API クラスの作成 (
AgentProxy):- 外部(自分自身)から叩かれるための窓口。ここでAgentを実行します。
- 設定 (Named Credential):
- SalesforceがSalesforceにログインするための「通行手形」を作ります(ここが管理者権限になります)。
- コントローラーの修正 (
PropertySearchController):- 直接Agentを呼ばずに、2の設定を使って1のAPIを呼び出します。
ステップ1: REST API クラス (AgentProxy.cls)
管理者権限で動作する、専用のAPIエンドポイントを作成します。
@RestResource(urlMapping='/AgentProxy')
global without sharing class AgentProxy {
// リクエストのボディ構造
global class AgentRequest {
public String userQuery;
public String agentApiName;
public String sessionId;
}
@HttpPost
global static void invokeAgentProxy() {
RestRequest req = RestContext.request;
RestResponse res = RestContext.response;
try {
// ボディをパース
String jsonBody = req.requestBody.toString();
AgentRequest requestData = (AgentRequest)JSON.deserialize(jsonBody, AgentRequest.class);
// ★ここでAgentを実行(このクラスは管理者権限で動く)
Invocable.Action action = Invocable.Action.createCustomAction('generateAiAgentResponse', requestData.agentApiName);
action.setInvocationParameter('userMessage', requestData.userQuery);
if (String.isNotBlank(requestData.sessionId)) {
action.setInvocationParameter('sessionId', requestData.sessionId);
}
List<Invocable.Action.Result> results = action.invoke();
if (results.size() > 0 && results[0].isSuccess()) {
// 成功時は結果をそのまま返す
res.responseBody = Blob.valueOf(JSON.serialize(results[0].getOutputParameters()));
res.statusCode = 200;
} else {
// エラーハンドリング
String errorMsg = 'Agent Error';
if (results.size() > 0 && results[0].getErrors().size() > 0) {
errorMsg += ': ' + results[0].getErrors()[0].getMessage();
}
res.responseBody = Blob.valueOf(errorMsg);
res.statusCode = 500;
}
} catch (Exception e) {
res.responseBody = Blob.valueOf('Proxy Error: ' + e.getMessage());
res.statusCode = 500;
}
}
}
ステップ2: 指定ログイン情報設定
ここが少し複雑ですが、「SalesforceからSalesforceへ、管理者としてアクセスする設定」 を行います。
- 接続アプリケーション (Connected App) の作成:
- [設定] > [外部クライアントアプリケーションマネージャー] > [新規外部クライアントアプリケーション]
- 名前:
AgentLoopback - 取引先責任者 メール: 自身のメールアドレス
- コールバックURL:
https://(あなたのドメイン).my.salesforce.com/services/authcallback/AgentAuth - OAuth設定の有効化: ON
- 選択したOAuth範囲:
- 「API を使用してユーザーデータを管理 (api)」
- いつでも要求を実行 (refresh_token, offline_access)
- 保存後、「コンシューマ鍵 (Client ID)」 と 「コンシューマの秘密 (Client Secret)」 をメモします。
- 認証プロバイダー (Auth Provider) の作成:
- [設定] > [認証プロバイダー] > [新規]
- プロバイダタイプ:
Salesforce - 名前:
AgentAuth - コンシューマ鍵/秘密: 先ほどメモしたものを入力。
- 保存後、表示される 「コールバックURL」 をコピーし、1の接続アプリケーション設定に戻って「コールバックURL」を正しいものに修正して保存します。
- 指定ログイン情報 (Named Credential) の作成:
- [設定] > [指定ログイン情報] > [新規 (従来)]
- ラベル:
LocalAgentCallout - 名前:
LocalAgentCallout - URL:
https://(あなたのドメイン).my.salesforce.com(今の組織のURL) - ID種別: 指定ユーザー (Named Principal)
- 認証プロトコル:
OAuth 2.0 - 認証プロバイダ:
AgentAuth(さっき作ったもの) - スコープ:
api refresh_token(半角スペース込みでそのままコピペしてください) - 保存時に認証フローを開始: チェックあり
- 「保存」を押すとログイン画面が出るので、管理者としてログインして「許可」します。
- 認証ステータスが 「認証済み (Authenticated)」 になれば成功です。

ステップ3: コントローラーの定義 (PropertySearchController.cls)
LWCから呼ばれるメソッドを書き換えます。 「サイトユーザーならプロキシ経由、管理者なら直接実行」という分岐を入れるとテストもしやすいです。
@AuraEnabled
public static Map<String, Object> invokeAgent(String userQuery, String agentApiName, String sessionId) {
try {
// 現在のユーザータイプを確認
String userType = UserInfo.getUserType();
// パートナーユーザー ('PowerPartner') などの場合はプロキシ経由で実行
if (userType == 'PowerPartner' || userType == 'CspLitePortal') {
return invokeAgentViaProxy(userQuery, agentApiName, sessionId);
} else {
// 内部ユーザーは直接実行(既存ロジック)
return invokeAgentDirectly(userQuery, agentApiName, sessionId);
}
} catch (Exception e) {
System.debug('Exception: ' + e.getMessage());
throw new AuraHandledException('Agent Error: ' + e.getMessage());
}
}
// ★指定ログイン情報を使ったループバック呼び出し
private static Map<String, Object> invokeAgentViaProxy(String userQuery, String agentApiName, String sessionId) {
HttpRequest req = new HttpRequest();
// 指定ログイン情報 'LocalAgentCallout' を使用
req.setEndpoint('callout:LocalAgentCallout/services/apexrest/AgentProxy');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
// タイムアウトを最大の120秒(2分)に設定
// デフォルトは10秒のため、AIの回答待ちには短すぎます
req.setTimeout(120000);
Map<String, String> bodyMap = new Map<String, String>{
'userQuery' => userQuery,
'agentApiName' => agentApiName,
'sessionId' => sessionId
};
req.setBody(JSON.serialize(bodyMap));
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() == 200) {
return (Map<String, Object>)JSON.deserializeUntyped(res.getBody());
} else {
throw new CalloutException('Proxy Error (' + res.getStatusCode() + '): ' + res.getBody());
}
}
// 既存のロジックをprivateメソッドに退避
private static Map<String, Object> invokeAgentDirectly(String userQuery, String agentApiName, String sessionId) {
Invocable.Action action = Invocable.Action.createCustomAction('generateAiAgentResponse', agentApiName);
action.setInvocationParameter('userMessage', userQuery);
if (String.isNotBlank(sessionId)) {
action.setInvocationParameter('sessionId', sessionId);
}
List<Invocable.Action.Result> results = action.invoke();
if (results.size() > 0 && results[0].isSuccess()) {
return results[0].getOutputParameters();
} else {
String errorDetails = 'Agent Error: ';
if (results.size() > 0 && results[0].getErrors().size() > 0) {
errorDetails += results[0].getErrors()[0].getMessage();
}
throw new CalloutException(errorDetails);
}
}
この構成にすれば、パートナーユーザーがボタンを押しても、裏側では「指定ログイン情報に設定された管理者」としてAgentが実行されるため、ライセンスの壁を越えられます。 設定項目が多くて大変ですが、これが最も確実な回避策です。
Agentforceを呼び出す下準備
LWCから呼び出すサービスエージェントを作成します。ここはいつもどおり、以下の流れで準備しましょう。
- Data 360 レトリーバー作成
- プロンプトテンプレート作成
- サービスエージェント作成
Data 360 レトリーバー作成
以下の4オブジェクトをData 360に取り込み、カスタムDMOにマッピングします。Salesforce(構造化データ)では1つの住戸に対して複数の設備が存在する構成になっています。
- 物件 (Building): 募集住戸がある「建物」。
- 募集住戸 (Listing): 貸し出しを募集している「部屋」。
- 設備マスタ (Feature): 「オートロック」「システムキッチン」などの設備定義。
- 住戸設備 (ListingFeature): どの部屋にどの設備があるかを繋ぐ中間テーブル。

AIは「1つのレコードに全ての文脈が詰まっている」状態を好みます。1行の住戸DMOにすべての情報をまとめることができれば検索性が非常に高くなります。1対nのデータを1行に結合する方法の詳細は以下の記事をご覧ください。
そもそもData 360への取り込みから知りたい方は以下の記事がおすすめです。
ただし今回はデータレイクオブジェクト「物件検索用統合DLO」とマッピング先のデータモデルオブジェクト「物件検索用統合オブジェクト」を自作しています。
プロンプトテンプレート作成
エージェントが呼び出すプロンプトテンプレートを定義します。

あなたは不動産仲介パートナーを支援する優秀なアシスタントです。
以下の「検索された物件情報」を使用して、ユーザーの質問に回答してください。
# 検索された物件情報
{!$EinsteinSearch:Listing_Retriever_1Cx_5Olaa10b5a9.results}
# ユーザーの質問
{!$Input:user_query}
# 回答のガイドライン
1. 上記の「検索された物件情報」に記載されている情報のみを根拠としてください。載っていないことは「情報がありません」と答えてください。
2. 提案する際は、ユーザーの質問(意図)と、物件の特徴(結合テキスト)がどうマッチしているかを具体的に説明してください。
3. 物件名と間取りと家賃は必ず明記してください。
4. 物件を紹介する際は、必ず物件名の後ろに [ID: a02...] の形式でListing Idを出力してください。例)物件名:ベイサイドタワー横浜 2505 [ID: a02gK00000BI9oCQAT] \n 間取り:...
サービスエージェント作成
Agentforce Service Agent を作成します。私はパートナーコンシェルジュというエージェントを作成しました。作り方の基本は以下の記事をご覧ください。
- トピックを作成
- 名前: 物件検索と提案
- 分類の説明: ユーザーが賃貸物件を探している場合や、特定の条件(エリア、予算、設備など)に合う部屋の提案を求めている場合に使用します。「おしゃれな部屋」「静かな場所」といった抽象的な相談や、おすすめの物件を知りたい場合にも使用します。
- 範囲: 管理物件(Listing__c)および建物情報(Building__c)の検索、詳細情報の提供、および提案理由の説明に限定されます。契約締結手続き、入居審査の判定、および退去手続きは範囲外です。
- 指示:
- (ひとつめの指示は下記を参照)
- 必ず日本語で回答すること。
- [ID: a02…] の部分を省略せずそのまま出力すること。
- トピックのアクションを作成
- プロンプトテンプレート「物件提案RAG」を実行
- エージェントアクション指示: ユーザーの曖昧な要望や条件に基づいて、Data Cloud上の物件情報を検索し、最適な物件を提案します。
- min_room: 部屋数の希望があれば数値で抽出してください(例:2LDK→2、3部屋以上→3)。
- user_query: ユーザーの入力全体、または検索に関する要望の文章をそのまま渡してください。
- max_rent: ユーザーが予算を指定している場合、数値を抽出してください(例:15万→150000)。なければ空で構いません。
- Prompt Response: 会話に表示にチェック
- プロンプトテンプレート「物件提案RAG」を実行
指示ひとつめ
あなたは仲介パートナーのアシスタントとして、以下の手順とルールに従って物件を提案してください。
1. 要件の把握と即時検索:
ユーザーの入力から検索条件(エリア、予算、希望など)を読み取ってください。「広い部屋」「海が見える」といった定性的な(曖昧な)要望であっても、ユーザーに詳細を質問し返さず、まずはその言葉のニュアンスを含めて検索アクション(RAG_Property_Search)を実行してください。質問を行うのは、ユーザーの要望が極端に少ない場合(例:「物件探して」のみ等)に限ってください。
2. 検索アクションの実行:
物件を探す際は、必ずアクション `RAG_Property_Search` を使用してください。このアクションへの入力には、単語への分解を行わず、ユーザーが話した文章やニュアンス(例:「海が見えて開放感のある部屋」)をそのまま渡すことを優先してください。
3. 回答の生成とフォーマット:
アクションから返ってきた情報に基づき、原則として最大3件まで提案してください。以下のフォーマットを遵守してください。
- 物件名(部屋番号含む):`### 1. 物件名 部屋番号 [ID: a02...]`の形式で必ず「1.」のような番号をつけたh3タイトルとすること
- 賃料
- 間取り
- おすすめ理由: 検索結果に含まれる「AIによる解説」を引用し、なぜその物件がユーザーの感性に合うのかを魅力的に伝えてください。
4. 結果がない場合の対応:
もし検索結果が0件、または「情報がありません」という結果だった場合は、ユーザーに「条件を少し広げて(例:エリアを変える、予算を上げるなど)再検索しますか?」と提案してください。
5. 次のアクション:
物件を提案した後は、詳細情報を見るか、または内見予約に進むかをユーザーに確認してください。
LWCによるAI回答の解析 (homeAgentHero)
AIからのレスポンスは単なるテキストです。これをリッチなUI(物件カード)に変換するために、LWC側で工夫を凝らしました。
コンシェルジュへの指示(プロンプト設計)
まず、プロンプトビルダーとエージェントに 「物件を紹介する際は、必ず [ID: レコードID] という形式を含める」 旨の指示をしておきます。
正規表現によるID抽出
LWCのJavaScriptで、AIの回答からこのIDを抜き出し、画面表示用のデータと、裏で検索するためのIDリストに分離します。
homeAgentHero.js
/**
* Agentの回答を解析し、ID抽出とマークダウン→HTML変換を行う
*/
parseAgentResponse(rawText) {
// [ID: xxxxx] のパターンを定義
const idRegex = /\[ID:\s*([a-zA-Z0-9]{15,18})\]/g;
const ids = [];
let match;
// テキスト内から全てのIDを抽出
while ((match = idRegex.exec(rawText)) !== null) {
ids.push(match[1]);
}
// 1. 画面表示用にIDタグを削除
let formattedText = rawText.replace(idRegex, '').trim();
// 2. 簡易マークダウン変換 (HTML化)
// AgentはMarkdownで返してくるため、RichTextで見れるようにHTMLタグへ置換
formattedText = formattedText.replace(/\*\*(.*?)\*\*/g, '<b>$1</b>'); // 太字
formattedText = formattedText.replace(/\n/g, '<br>'); // 改行
return { cleanText: formattedText, ids };
}
このロジックにより、画面上のチャット吹き出しには「IDタグのない自然な文章」が表示され、その横に「抽出されたIDに基づく物件カード」が表示される仕組みです。
非同期処理と状態管理
AIの回答を待つ間、ユーザーを待たせないためのローディング表示や、Enterキーの制御も重要です。 日本語入力(IME変換)中のEnterキーで誤送信しないよう、isComposing プロパティなどをチェックしています。
homeAgentHero.js
handleEnter(event) {
// IME変換中(isComposingがtrue)またはコード229の場合は処理を中断
if (event.isComposing || event.keyCode === 229) {
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const query = event.target.value;
if (query && query.trim().length > 0) {
this.executeAiSearch(query);
}
}
}
デザインのこだわり(ヒーローエリア)
トップページとしての見栄えを良くするため、背景画像の上に検索バーを配置しています。 background-image にオーバーレイ(半透明の黒)を重ねることで、白文字のタイトルを読みやすくするCSSテクニックを使用しています。
homeAgentHero.css
/* 背景を暗くして文字を見やすく */
.hero-background::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.4);
}
.hero-content {
position: relative; /* overlayの上に表示 */
z-index: 1;
}
まとめ
Vol.4では、LWRサイトにAgentforceを統合する際の最大の難関である「権限問題」と「UI連携」を解決しました。
- 指定ログイン情報ループバック: 外部ユーザーでもAI機能をフル活用可能に。
- レスポンス解析: テキスト情報からIDを抜き出し、動的なUIを生成。
- Markdown変換: AIの出力をリッチテキストで綺麗に表示。
これで、検索から予約までがAIによってシームレスに繋がりました。 次回 Vol.5(最終回)では、これまで予約した内容や、自分(仲介業者)の担当スケジュールを確認するための 「マイページ・ダッシュボード」 を構築します。 リストビューとカレンダーを組み合わせた、実用的な管理画面の実装です。







読者の声