Microsoft Copilot Studio のエージェントを自作の Web アプリに組み込んでみた

Microsoft Copilot Studio のエージェントを自作の Web アプリに組み込んでみた

Copilot Studio のエージェントを自作アプリに組み込み、Agents SDK を使ってバックエンド経由で呼び出す実装に挑戦してみました。実装そのものより、ハーネスの選択が組み込み方に大きく影響することを学びました。
2026.09.14

製造ビジネステクノロジー部の小林です。

前回、Copilot Studio でエージェントを作って公開するところまでをやってみました。

https://dev.classmethod.jp/articles/shoma-copilot-studio-first-agent/

そのときは Copilot Studio が用意したデモ Web サイトで動かしただけだったので、今回は自分で作った Web アプリの画面にチャットを組み込んでみます。

アプリ側からエージェントをどう呼び出すのかを見ていきます。

やってみたら、実装よりも「どのエージェントなら呼べるのか」で何度も足止めを食いました。そのあたりも含めて書いていきます!

今回作るもの

React の画面にチャット UI を自前で用意して、バックエンド経由で Copilot Studio のエージェントに質問を投げます。

バックエンドを経由させている理由は、エージェント呼び出し用のトークンを取得する際にクライアントシークレットが必要になるためです。
シークレットをブラウザ側に置くことはできないため、サインインとエージェント呼び出しはサーバー側で完結させる構成にしています。

エージェントを組み込む方法は 2 つある

Copilot Studio の Channels には App という項目があり、ここに Web アプリへの組み込み方が 2 つ並んでいます。

スクリーンショット 2026-09-14 17.23.54

1 つは Embed code です。コピーしたスニペットを HTML に貼り付けるだけで組み込めますが、中身は iframe になっており、チャット UI は Microsoft が用意したものがそのまま表示されます。手間がかからない反面、アプリ側からエージェントを呼び出すコードを書くことはできません。

もう 1 つが Microsoft 365 Agents SDK です。接続文字列や環境 ID を使い、自分のコードからエージェントと会話します。その代わり、チャット UI は自分で実装する必要があります。

https://learn.microsoft.com/ja-jp/microsoft-copilot-studio/publication-integrate-web-or-native-app-m365-agents-sdk

今回はアプリからの呼び出し方を書きたかったので、後者を選びました。

余談

iframe を貼るだけで済むなら、わざわざ SDK を使う理由は何か!?
という話ですが、要はエージェントとの会話にアプリ側が介入できるかどうかです。

SDK であれば、送信前にプロンプトへコンテキストを差し込んだり、応答を受けてアプリの状態を変えたり(画面遷移、フォームの自動入力)といった処理を挟めます。

認証の扱いや会話ログの保存先を自分で決められるのも同じ理由です。

標準ハーネスと GitHub Copilot ハーネスの違い

先に整理しておきたいのが、ハーネスの違いです。Copilot Studio で作ったものはすべてハーネスと呼ばれる実行エンジンの上で動きます。エージェントの一覧にも Powered by という列があって、どちらのハーネスで動いているかが表示されます。

スクリーンショット 2026-09-14 17.26.03

https://learn.microsoft.com/ja-jp/microsoft-copilot-studio/harnesses-overview

一方の GitHub Copilot ハーネスは、指示とナレッジを与えて推論に任せる形で、多段の作業や複雑な業務プロセスに向いています。

今回、同じ社内 FAQ エージェントを両方のハーネスで作ることになったので、触って気づいた違いを並べておきます。

観点 標準ハーネス GitHub Copilot ハーネス
編集画面 概要・ナレッジ・ツール・トピック・評価・監視・チャネルのタブ Build / Preview / Evaluate / Monitor
モデル 一覧から選択(既定は GPT-5.5 Chat) Claude Opus 5 が既定
言語 作成時に日本語を選べる Primary language の既定が English
スキーマ名 作成時に自分で指定できる 自動生成され、後から変更できない
メタデータ 設定の「上級」に環境 ID・テナント ID・スキーマ名がまとまっている Agent details にスキーマ名のみ
課金 公開して使われてから 作成・テスト・評価の時点から Copilot Credits を消費
チャネル Teams、Microsoft 365、SharePoint、WhatsApp、Azure Bot Service 経由の Slack など Teams、Microsoft 365、デモ Web サイト、Web アプリの 4 つ
外部アプリからの呼び出し Agents SDK や Direct Line で呼べる Agents SDK は非対応

使い分けの目安は、外部からの呼び出しの有無だと思います。自分のアプリや他システムから呼びたい場合、あるいは Slack など Microsoft 以外のチャネルに出したい場合は標準ハーネス。

Teams や Microsoft 365 Copilot の中で完結させるなら GitHub Copilot ハーネス、という分かれ方になります。

事前準備: Entra ID のアプリ登録

Agents SDK でエージェントを呼ぶには、Microsoft Entra ID にアプリ登録が必要です。利用者としてサインインし、そのトークンでエージェントを呼び出す、という流れになるためです。

https://learn.microsoft.com/ja-jp/entra/identity-platform/quickstart-register-app

ところが Entra 管理センターの「アプリの登録」から新規登録したところ、作成自体は成功したのですが、直後に開いた管理画面が 403 になりました。

スクリーンショット 2026-09-14 17.45.42

403 の画面では下記のメッセージが表示されました。

管理者が Azure portal のアプリの登録エクスペリエンスを無効にしました。
PowerShell または別のクライアント (Visual Studio など) を使用して、
引き続きアプリケーションを登録または管理することができます。

画面からは操作できないものの、コマンドからなら操作できるということなので、Azure CLI に切り替えます。

az login --allow-no-subscriptions
az ad app list --display-name "FAQ Agent" --query "[].{name:displayName,appId:appId}" -o table

スクリーンショット 2026-09-13 23.42.30

続いて、エージェントを呼ぶための委任アクセス許可を追加します。必要なのは Power Platform API の CopilotStudio.Copilots.Invoke です。

# Power Platform API のアプリ ID は固定値。まずスコープの ID を引く
SCOPE_ID=$(az ad sp show --id 8578e004-a5c6-46e7-913e-12f58912df43 \
  --query "oauth2PermissionScopes[?value=='CopilotStudio.Copilots.Invoke'].id | [0]" -o tsv)

az ad app permission add \
  --id <アプリのクライアント ID> \
  --api 8578e004-a5c6-46e7-913e-12f58912df43 \
  --api-permissions "${SCOPE_ID}=Scope"

最後に、認証コードを受け取るリダイレクト URI と、バックエンドが使うクライアントシークレットを用意します。

az ad app update --id <アプリのクライアント ID> \
  --web-redirect-uris "http://localhost:8787/auth/callback"

az ad app credential reset --id <アプリのクライアント ID> \
  --display-name local-dev --years 1 --append

シークレットはこのコマンドの出力にだけ表示されます。.env に控えて、リポジトリには入れないようにします。

自作アプリの構成

フロントエンドとバックエンドを分けた構成にしました。

copilot-studio-chat/
├── backend/
│   ├── .env
│   ├── package.json
│   ├── tsconfig.json
│   └── src/
│       └── index.ts
└── frontend/
    ├── package.json
    ├── vite.config.ts
    └── src/
        ├── App.tsx
        ├── App.css
        └── main.tsx

バックエンドは TypeScript + Hono、フロントエンドは Vite + React です。

バックエンド: エージェントに接続する

まず、どのエージェントに繋ぐかの設定と、トークンを取るためのクライアントを用意します。

backend/src/index.ts
/**
 * Copilot Studio 側のエージェントを一意に指す設定。
 * 接続文字列が発行されない環境でも動くよう、環境 ID とスキーマ名を個別に渡している。
 */
const connectionSettings = new ConnectionSettings({
  environmentId: env('ENVIRONMENT_ID'),
  schemaName: env('SCHEMA_NAME'), // 表示名ではなく、作成時に決まる内部名
})

/** エージェント呼び出しに必要な委任スコープ。SDK が設定から組み立ててくれる。 */
const scope = ScopeHelper.getScopeFromSettings(connectionSettings)

/**
 * ユーザーの代理でトークンを取る機密クライアント。
 * シークレットを持つのでブラウザ側には置けず、必ずサーバー側に閉じ込める。
 */
const msal = new ConfidentialClientApplication({
  auth: {
    clientId: env('CLIENT_ID'),
    clientSecret: env('CLIENT_SECRET'),
    authority: `https://login.microsoftonline.com/${env('TENANT_ID')}`,
  },
})

エージェントの指定方法は、接続文字列(directConnectUrl)を渡すか、環境 ID とスキーマ名を組み合わせるかの 2 通りあります。今回は Copilot Studio 側の接続文字列の欄が空のままだったので、後者を使いました。

このスキーマ名が表示名とは別物なので、最初に少し戸惑いました。設定の「上級」にあるメタデータか、編集画面の URL の bots/ の後ろで確認できます。

次に、サインインの入口と認証コードを受け取る側です。

backend/src/index.ts
app.get('/auth/login', async (c) => {
  const url = await msal.getAuthCodeUrl({
    scopes: [scope],
    redirectUri: env('REDIRECT_URI'),
  })
  return c.redirect(url)
})

app.get('/auth/callback', async (c) => {
  const code = c.req.query('code')
  if (!code) return c.text('認証コードがありません', 400)

  const result = await msal.acquireTokenByCode({
    code,
    scopes: [scope],
    redirectUri: env('REDIRECT_URI'),
  })

  const sessionId = randomUUID()
  sessions.set(sessionId, { accessToken: result.accessToken })

  setCookie(c, 'sid', sessionId, {
    httpOnly: true, // トークンを持つ鍵なので JS からは触らせない
    sameSite: 'Lax',
    path: '/',
  })
  return c.redirect(env('FRONTEND_ORIGIN'))
})

ブラウザに渡しているのはセッション ID だけで、アクセストークン自体はサーバー側に置いたままにしています。セッションはメモリ上の Map に持たせていますが、これは検証用だからで、実運用なら外部ストアに逃がすところです。

なお、リダイレクト URI はアプリ登録に入れた値と 1 文字でも違うとサインインが弾かれます。

バックエンド: 質問を投げて応答を受け取る

いよいよ呼び出し部分です。

backend/src/index.ts
app.post('/api/chat', async (c) => {
  const session = currentSession(c)
  if (!session) return c.json({ error: 'サインインしていません' }, 401)

  const { message } = await c.req.json<{ message: string }>()
  const client = new CopilotStudioClient(connectionSettings, session.accessToken)

  // 初回だけ会話を開始し、以降は同じ conversationId を使い回して文脈を保つ
  if (!session.conversationId) {
    const started = await client.startConversationWithResponse(true)
    session.conversationId = started.conversationId
  }

  const response = await client.executeWithResponse(
    Activity.fromObject({ type: 'message', text: message }),
    session.conversationId
  )

  const replies = response.activities
    .filter((activity) => activity.type === 'message' && activity.text)
    .map((activity) => activity.text as string)

  return c.json({ replies })
})

CopilotStudioClient は設定とトークンを渡すだけで作れます。トークンは利用者ごとに違うので、使い回さずリクエストのたびに作る形にしました。

会話は startConversationWithResponse で開始し、返ってきた conversationId を保持しておきます。ここを渡し忘れると毎回新しい会話として扱われてしまい、前のやり取りを踏まえた回答が返ってきません。

応答は Activity の配列で返ってきます。挨拶やイベントなどテキストを持たないものも混ざっているので、type が message でテキストがあるものだけを拾っています。

フロントエンド: 自前のチャット UI

React 側は、バックエンドの API を叩くだけです。Copilot Studio の SDK はフロントエンドには入れません。

frontend/src/App.tsx
const send = async () => {
  const text = input.trim()
  if (!text || sending) return

  setMessages((prev) => [...prev, { role: 'user', text }])
  setInput('')
  setSending(true)

  try {
    const res = await fetch(`${BACKEND}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include', // セッション Cookie を送るために必要
      body: JSON.stringify({ message: text }),
    })
    const data = await res.json()
    const replies: string[] = data.replies ?? [data.error ?? '応答がありませんでした']
    setMessages((prev) => [
      ...prev,
      ...replies.map((reply) => ({ role: 'agent' as const, text: reply })),
    ])
  } finally {
    setSending(false)
  }
}

見落としやすいのが credentials: 'include' です。これを忘れるとセッション Cookie が送られず、サインイン済みでも常に未サインイン扱いになります。

あわせてバックエンド側の CORS でも、credentials: true と具体的なオリジンの指定が必要です。

動かしてみたら、ハーネスの壁に当たった

ここまでで、サインインからチャットまで一通り動くはずでした。実際にサインインして質問を投げたところ、返ってきたのはこれです。

スクリーンショット 2026-09-14 0.28.37

This action doesn't support agents built with the GitHub Copilot harness.

注意したいのは、これがエラーではなくエージェントからの応答として返ってきている点です。認証もトークン取得も会話の開始も成功していて、そのうえで「この呼び出し方は GitHub Copilot ハーネスのエージェントには対応していない」と言われている状態です。

つまりアプリ側のコードに問題があるわけではなく、Agents SDK から呼べるのは標準ハーネスのエージェントだけ、ということです。ハーネスは後から切り替えられないので、標準ハーネスで同じエージェントを作り直します。

標準ハーネスでエージェントを作り直す

ホーム画面の「Other ways to build」から、Standard のバッジが付いた Agent を選びます。

スクリーンショット 2026-09-14 0.30.15

作成時のダイアログで、言語とスキーマ名を指定できます。

スクリーンショット 2026-09-14 18.39.03

ここが GitHub Copilot ハーネスとの分かりやすい違いでした。あちらは名前を付ける前にスキーマ名が確定してしまい、あとから変更できません。実際、表示名を変えたあとも cra95_untitledagent_aVrR3u のままでした。標準ハーネスなら cra95_FAQAgent のように、後で見て分かる名前を付けられます。

言語も最初から日本語を選べます。GitHub Copilot ハーネスで作ったエージェントは Primary language が English のままで、デモ Web サイトでの挨拶が英語になっていました。

作成後は、前回と同じ指示と、社内規程を書いた Word ファイルをナレッジに設定して公開します。

スクリーンショット 2026-09-14 22.45.52

スクリーンショット 2026-09-14 22.46.21

アプリ側で必要なのは、バックエンドの .env を差し替えるだけです。

backend/.env
ENVIRONMENT_ID=Default-6e4c9576-XXXXX-XXXXX-XXXX-XXXXXXXX
SCHEMA_NAME=cra95_FAQAgent

環境 ID とスキーマ名は、設定の上級にあるメタデータから確認できます。

スクリーンショット 2026-09-14 22.33.41

動作確認

バックエンドを再起動して、もう一度サインインしてから質問を投げます。

スクリーンショット 2026-09-14 22.38.00

スクリーンショット 2026-09-14 22.39.05

スクリーンショット 2026-09-14 22.39.40

ナレッジに入れたドキュメントの記載どおりで、参照番号も付いています。自分で作った画面から、自分のアカウントでサインインして、社内ドキュメントを根拠にした回答を受け取れました。

ひとつ気になったのが、回答の末尾に [1]: cite:1 "Citation-1" という行がそのまま出ていることです。Embed code の Web チャットなら参照元がリンクとして整形されますが、自前の UI には Activity のテキストがそのまま届きます。

参照元を見せたいなら、この表記を自分で解釈して整形することになりそうです。自由に作れる代わりに、こうした細かい部分は自分で面倒を見る必要がある、ということですね。

まとめ

自作アプリから Copilot Studio のエージェントを呼ぶだけの内容でしたが、詰まったのはすべて実装以外の部分でした。

Entra 管理センターのアプリ登録画面が無効化されていた件は Azure CLI で回避できましたが、ハーネスの違いは作り直すしかありませんでした。

今後、GitHub Copilot ハーネスのエージェントも自分のアプリから呼べるようになることに期待です!

この記事をシェアする

関連記事