自社システムへの組み込み
このページの内容
SotaAgents は自社が所有するページの中で、フローティングバブルまたはインラインパネルとして動作します。ユーザーは自社のシステムにサインインしたままで、SotaAgents のログイン画面を見ることはありません。ユーザーが誰であるかは自社のバックエンドが保証し、プラットフォームはユーザーの入力ではなくその署名を信頼します。
2 つの組み込み方式#
| 方式 | 訪問者 | 用途 | 設定者 |
|---|---|---|---|
| Public Deployment (Configure Embed) | 匿名ゲスト。専用の Guest Credit pool を消費 | 公開サイト、マーケティングページ、プリセールスチャット | 組織の Owner / Admin — コード不要 |
| Embed SDK (署名付き handshake) | 組織のメンバーである実名ユーザー | イントラネット、顧客ポータル、社内ツール | 組織の Owner / Admin と開発チーム |
本ページは後者を扱います。前者はワークスペースを参照してください。
始める前に#
- Console → 組織 → API Keys で発行する API key と secret。secret は一度しか表示されません — API キーを参照。
- embed を使う全員が、あらかじめ組織の有効なメンバーである必要があります。just-in-time のアカウント作成は行いません。未知のユーザーは作成されずに拒否されます。先に招待するか、SSO でプロビジョニングしてください。
- 自社で管理するバックエンド。secret はサーバー側で署名に使うものであり、ブラウザーに渡してはいけません。
handshake の流れ#
自社ページが自社バックエンドに ticket を要求
SDK が自社の ID 基盤と直接やり取りすることはありません。開発者が渡す getTicket 関数を呼び、その関数が自社のセッション cookie を付けて自社エンドポイントを呼びます。
自社バックエンドが署名して ticket を発行
セッションからユーザー identity を読み、API secret でリクエストに署名し、60 秒有効な単回使用 ticket を受け取ります。
iframe が ticket を session に交換
SDK が ticket を SotaAgents フレームに渡し、フレームが自身の session に交換します。ticket は再利用できません。
SDK が自動で ticket を取り直す
有効期限の直前、および 401 の後には、SDK が自動で手順 1 を繰り返します。意味を持つ有効期間は自社セッションだけで、同期すべきものはありません。
バックエンドの責務#
3 つあり、最初の 2 つが統合で最も間違えやすい箇所です。
サーバー側でユーザーを特定する
既存のポータルの仕組み(SSO、OIDC、独自のセッションテーブル)をそのまま使います。externalUserId はその人に対して恒久的に不変である必要があります。SotaAgents ユーザーへの対応付けは一度だけ作られ、更新されることはありません。これは自社側で守るべき不変条件であり、SotaAgents 側で強制しているものではありません。既知の人物に新しい id が届いても拒否されません。SotaAgents は email で本人を突き合わせ、2 つ目の対応付けを黙って作成するため、古い対応付けは identity 失効の対象から外れたまま残ります。拒否されるのは逆のケース、既存の id が別の email で届いた場合です。
ticket エンドポイントを用意する
identity は必ずセッションから読み、リクエストボディからは読まないでください。ボディから読むと、サインイン済みの社員が同僚になりすました ticket を発行できてしまいます。以下の手順で呼び出しに署名し、ticket をブラウザーに返します。
SDK が判断できる status を返す
SDK は 4xx を恒久的なエラーとして停止し、5xx を再試行可能として backoff します。自社セッション切れは 401、有効なメンバーシップがない場合は 409、SotaAgents に到達できない場合は 502 を返してください。
ブラウザーが受け取るのは短命な単回使用 ticket だけです。フロントエンドに配られた API secret は、組織内のあらゆる identity の ticket を発行できます。secret manager に保管し、source control には入れないでください。
リクエストへの署名#
手順はどの言語でも同じです。5 行の canonical string を組み立て、API secret で HMAC-SHA256 し、v1=<小文字 hex> として送ります。5 行は method、path、Unix タイムスタンプ(秒)、nonce、リクエストボディの SHA-256 で、\n で連結し、末尾に改行は付けません。
POST
/api/embed/auth/tickets
1786800000
3f1c9a7e-5b02-4d6f-9a11-0c8e2d4b7f30
9b2a4c1d8e3f5a70b6c9d2e4f8a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9e1f3a5ボディの serialize は一度だけ行い、その同じ文字列を hash にも送信にも使ってください。2 回目のために再 serialize することが、どこでも検証できない署名になる最大の原因です。JSON エンコーダーが違えば、あるいは同じものを 2 回呼んだだけでも、キー順や空白が変わり得ます。
import { createHash, createHmac, randomUUID } from 'node:crypto';
const body = JSON.stringify({ externalUserId, email, name }); // serialize ONCE
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomUUID();
const bodyHash = createHash('sha256').update(body, 'utf8').digest('hex');
const canonical = ['POST', PATH, timestamp, nonce, bodyHash].join('\n');
const signature = 'v1=' + createHmac('sha256', apiSecret).update(canonical).digest('hex');import hashlib, hmac, json, time, uuid
body = json.dumps({"externalUserId": external_user_id, "email": email}) # serialize ONCE
timestamp = str(int(time.time()))
nonce = str(uuid.uuid4())
body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
canonical = "\n".join(["POST", PATH, timestamp, nonce, body_hash])
signature = "v1=" + hmac.new(
api_secret.encode("utf-8"), canonical.encode("utf-8"), hashlib.sha256
).hexdigest()String body = objectMapper.writeValueAsString(payload); // serialize ONCE
String timestamp = String.valueOf(Instant.now().getEpochSecond());
String nonce = UUID.randomUUID().toString();
String bodyHash = HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(body.getBytes(StandardCharsets.UTF_8)));
String canonical = String.join("\n", "POST", PATH, timestamp, nonce, bodyHash);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = "v1=" + HexFormat.of().formatHex(
mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));var body = JsonSerializer.Serialize(payload); // serialize ONCE
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var nonce = Guid.NewGuid().ToString();
var bodyHash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(body))).ToLowerInvariant();
var canonical = string.Join("\n", "POST", Path, timestamp, nonce, bodyHash);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(apiSecret));
var signature = "v1=" + Convert.ToHexString(
hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();body, err := json.Marshal(payload) // serialize ONCE
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
nonce := uuid.NewString()
sum := sha256.Sum256(body)
bodyHash := hex.EncodeToString(sum[:])
canonical := strings.Join([]string{"POST", path, timestamp, nonce, bodyHash}, "\n")
mac := hmac.New(sha256.New, []byte(apiSecret))
mac.Write([]byte(canonical))
signature := "v1=" + hex.EncodeToString(mac.Sum(nil))$body = json_encode($payload); // serialize ONCE
$timestamp = (string) time();
$nonce = bin2hex(random_bytes(16));
$bodyHash = hash('sha256', $body);
$canonical = implode("\n", ['POST', $path, $timestamp, $nonce, $bodyHash]);
$signature = 'v1=' . hash_hmac('sha256', $canonical, $apiSecret);実際に送られるリクエスト#
どの言語で署名しても、SotaAgents が受け取るのはこの形です。timestamp と nonce のヘッダーは canonical string に入れたものと同じ値である必要があります。nonce は一度だけ受け付けられ、再送は 5 分間拒否されます。
POST https://app.sotaagents.ai/api/embed/auth/tickets
content-type: application/json
x-sota-key: sota_ek_...
x-sota-timestamp: 1786800000
x-sota-nonce: 3f1c9a7e-5b02-4d6f-9a11-0c8e2d4b7f30
x-sota-signature: v1=4d5e...
{"externalUserId":"e-1042","email":"an.tran@company.com"}| ヘッダー | 値 |
|---|---|
x-sota-key | API key(sota_ek_…)。secret ではありません |
x-sota-timestamp | 署名したものと同じ Unix 秒。5 分より古いリクエストは拒否されます |
x-sota-nonce | リクエストごとに一意。重複は replay として扱われます |
x-sota-signature | v1= に小文字 hex の HMAC を続けたもの |
SotaAgents は 201 と共通エンベロープ { success: true, data: { ticket, expiresAt }, timestamp } を返します。エンベロープを外してください。内側の data をブラウザーに返します。SDK は最上位の ticket を読むため、エンベロープのままでは不正な ticket と見なされ、通信は 200 なのにパネルが永久に表示されない状態になります。エラー応答は包まれません。{ statusCode, code, message } がそのまま返るので、code は最上位から読んでください。ticket の有効期間は 60 秒で、交換は 1 回だけです。
レート制限。ticket の発行は API key ごとに毎分 60 リクエスト、送信元 IP ごとに毎分 120 リクエストまでです。iframe 内での ticket 交換は毎分 10 回までで、エンドユーザーの IP と ticket の両方を単位に数えます。超過すると Retry-After 付きの 429 が返ります。汎用の 4xx にまとめず、そのまま SDK に渡してください。SDK が backoff して再試行します。バックエンド全体が 1 つの送信元 IP を共有し、1 つのオフィス NAT の背後にいる全員が交換の枠を共有するため、サインインのピークをこの数値と突き合わせ、足りない場合は公開前にご相談ください。
フロントエンドの責務#
<script src="https://app.sotaagents.ai/embed/sdk.js"></script> を読み込んで初期化します。重要な点は 2 つ。ticket 取得の呼び出しに自社セッション cookie を付けること、そしてサインアウト時に先に SotaAgents セッションを失効させることです。
以下のサンプルの origin は例です。tenant ごとの embed origin は SotaAgents 担当から受け取ってください。production と staging では異なります。この origin、SDK スクリプトの URL、ticket API のベース URL は、コードではなく設定に置いてください。HTTPS でない embedUrl は SDK が拒否します。
const embed = window.SotaEmbedSDK.createSotaEmbed({
embedUrl: 'https://app.sotaagents.ai/embed',
mode: 'bubble',
getTicket: async () => {
const response = await fetch('/api/sota/embed-ticket', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
// Status rides along so the SDK can fast-fail a 401 or 409 instead of
// retrying a permanent failure four times.
throw Object.assign(new Error('ticket_failed'), { status: response.status });
}
return response.json(); // { ticket, expiresAt }
},
});
// On sign-out: revoke the SotaAgents session BEFORE clearing your own.
const revoked = await embed.logout();
if (!revoked) console.warn('SotaAgents session may still be active');
await fetch('/api/logout', { method: 'POST', credentials: 'include' });SotaAgents のセッションは embed のオリジンに存在するため、自社セッションを消しても影響しません。logout() を省くと、そのブラウザーで次にサインインした人が生きたセッションを引き継ぎ、前のユーザーの会話に入り込みます。先に失効させ、その後に自社セッションを消してください。失効を確認できなかった場合は false を返します。自社ポータルからのサインアウトはそのまま進めつつ、共用端末やキオスクではセッションがまだ生きている可能性をユーザーに伝えてください。
自社ブランドに合わせる#
ここにある項目はすべて任意で、既定値はいずれも SotaAgents 標準のままです。何も設定しなければ見た目は今と変わりません。設定すれば、チャットは当社ではなく貴社の名前をまといます。
createSotaEmbed({
embedUrl: 'https://app.sotaagents.ai/embed',
getTicket,
// Inside the iframe
language: 'vi', // 'en' | 'vi' | 'ja'
branding: {
logoUrl: 'https://intranet.acme.com/logo.svg',
showUser: false,
greeting: 'How can the Acme assistant help?',
placeholder: 'Ask about policies, invoices, anything',
},
// The bubble chrome, drawn on your own page
mode: 'bubble',
title: 'Acme Assistant',
primaryColor: '#0b5cff',
position: 'bottom-left',
width: 420,
height: 640,
openOnLoad: false,
});2 つの設置形態。mode が取る値はちょうど 2 つです。既定は 'bubble' で、SDK が自社ページ上に独自のランチャーとパネルを描画し、container は無視されます。'inline' は指定した要素に iframe を直接置きます。ランチャーもパネルもなく、container が必須で、無い場合は生成時に例外を投げます。見積もりに関わる違いがもう 1 つあります。bubble は iframe を遅延生成し、最初のクリックまで作らないため、誰かがチャットを開くまで認証は走りません。inline は読み込み時に生成するため、ticket エンドポイントの不具合はすぐ表面化し、ページ表示ごとに ticket を 1 枚消費します。
チャット内部。 これらはサインイン後に届くのではなく iframe の URL に乗るため、ユーザーが最初に見るフレームからすでに貴社仕様です。SotaAgents のロゴが一瞬見えてから差し替わることはありません。
| オプション | 既定値 | 効果 |
|---|---|---|
language | ブラウザーに追従 | en、vi、ja(vn は vi として受け付けます)。固定ではなく初期値です。チャット内でユーザーが言語を切り替えた場合、そのセッションでは切り替え後の言語が保持されます |
branding.logoUrl | SotaAgents ワードマーク | サイドバーと挨拶文の横に表示される自社ロゴ。絶対 http(s) URL である必要があり、それ以外は無視されて既定に戻ります |
branding.showLogo | true | false でロゴを一切描画しません。logoUrl と併記した場合はこちらが優先されます |
branding.showUser | true | false でサイドバー下部のサインイン中ユーザー欄を隠します。自社ページ側で誰がサインインしているか既に示している場合はこちらが適切です |
branding.greeting | "How can I help you today?" | 新規チャット画面の一文 |
branding.placeholder | "Ask anything" | 入力欄のプレースホルダー |
greeting と placeholder は 120 文字で切り詰められます。一文には十分で、入力欄を画面外へ押し出すには短い長さです。
bubble の外枠。 SDK が iframe の外側、貴社ページ上に描画するランチャーとパネルです。title を除き mode: 'inline' では無視され、サイズは渡した container が、周囲の見た目は貴社ページが決めます。
| オプション | 既定値 | 効果 |
|---|---|---|
title | SotaAgents | パネルヘッダーの文字列。未設定ならヘッダーには操作ボタンだけが並びます。支援技術に向けた iframe の名前もこれが決め、そちらは mode: 'inline' でも有効なため、この表で唯一 bubble 専用ではない行です。未設定ならスクリーンリーダーはフレームを SotaAgents と読み上げます。white-label するなら設定してください |
primaryColor | #2f6bff | ランチャーとパネルヘッダー |
position | bottom-right | または bottom-left |
width / height | 400 / 620 | パネルのサイズ(px)。Expand ではこれを超えて広がります |
zIndex | 2147483000 | 自社の要素より下に置きたい場合は下げてください |
openOnLoad | false | true でランチャーのクリックを待たずに読み込み時点でパネルを開きます |
iframe 自体に関わるものがもう 2 つあります。allowMicrophone は既定でオフで、音声入力に必要です。sandbox は既定の allow-scripts allow-same-origin allow-forms allow-popups を置き換えます。ここは HTML の慣習とは逆で、sandbox: '' は最も厳しい設定ではなく、属性そのものを取り除きます。iframe の分離もろとも失われます。厳しくしたい場合は、より狭いトークンの並びを渡してください。空文字列は決して渡さないでください。理由がない限りどちらも触らないでください。
チャットは HTTPS で動くため、ブラウザーは http:// のロゴを黙って昇格させたうえでブロックします。自社のマークは消え、SotaAgents のマークが戻り、理由を示す失敗リクエストは network タブに現れません。ブランディングで最も多い落とし穴です。本番と同じ方式で配信されるページで確認してください。
サードパーティ cookie#
embed が自社ポータルと別サイトで動く場合、そのセッション cookie はサードパーティ cookie になります。
| ブラウザー | 挙動 | 結果 |
|---|---|---|
| Chrome、Edge | サイトごとに分割して cookie を保持 | 完全な session |
| Firefox | 既定でサードパーティ cookie を分割 | 完全な session |
| Safari | サードパーティ cookie をブロック | 10 分間のトークンにフォールバックし、自動で再認証 |
フォールバックでも動作しますが、数分ごとに再認証が走ります。reverse proxy で自社サイトのサブドメイン(例: ai.example.com)から embed を配信すると cookie が first-party になり、どのブラウザーでもこの問題がなくなります。その際の注意は 2 点。proxy に Set-Cookie を書き換えさせないこと、そしてチャット応答は stream されるためバッファリングを無効にすることです。
公開前チェック#
- secret は secret manager にあり、source control にはない。
- 対象ユーザー全員が組織の有効なメンバーである。
- ticket エンドポイントは identity をセッションからのみ読む。
- セッションを終了する全経路で、自社サインアウトの前に
logout()が実行される。 - Workspace の credit 配分が十分である(組み込み会話も通常の会話と同じく credit を消費します)。
- embed を frame できるオリジンを自社ドメインに限定するよう、サポートに依頼する。
すべてのオプション、コールバック、エラーコードは Embed SDK integration guide に記載されており、動作する参照実装も付属します。SotaAgents の担当者にお問い合わせください。