メインコンテンツに移動

builders.flash

Slack から日程調整 URL を丸投げできる AI エージェントを構築してみよう !

2026-07-02 | Author : 御田 稔 (AWS AI Hero)

Missing alt text value

はじめに

こんにちは ! AWS AI Hero のみのるんです。 AI エージェントが話題の昨今ですが、みなさん活用できていますでしょうか ?

AI エージェントというと、専用のチャット画面を用意して、そこに質問を投げるものを想像しがちです。それも便利ですが、日常の細かい作業では、Slack のようないつもの場所からそのまま頼める方がありがたい場面も多いでしょう。

例えば、多くの人が日常で遭遇しそうな雑務として「飲み会などの日程調整」を取り上げてみます。 よくある Web サービスで、みんなが都合の良し悪しを○や×で記入するタイプのものがあると思います。あの調整ページを人間が開いて、カレンダーとにらめっこしなくても、URL をそのままエージェントに丸投げできると便利ですよね。

今回は、Slack に日程調整ページの URL を投げると、エージェントがブラウザでページを開き、Google カレンダーと突き合わせ、迷わなければそのまま入力する Bot を作ります。


X ポスト » | Facebook シェア » | はてブ »

実際のイメージ

ご注意

本記事で紹介する AWS サービスを起動する際には、料金がかかります。builders.flash メールメンバー特典の、クラウドレシピ向けクレジットコードプレゼントの入手をお勧めします。

*ハンズオン記事およびソースコードにおける免責事項 »

builders.flash メールメンバー登録

builders.flash メールメンバー登録で、毎月の最新アップデート情報とともに、AWS を無料でお試しいただけるクレジットコードを受け取ることができます。

今すぐ登録 »

このハンズオンについて

このサンプルでは、エージェントの中身を Strands Agents (https://strandsagents.com/) で書きます。 Strands Agents は、モデル・システムプロンプト・ツールを組み合わせてエージェントを作るためのオープンソース SDK です。簡単なコードで賢いエージェントを書けるのが特徴で、Python と TypeScript に対応しています。

その Strands Agent を AWS 上で動かす場所として、最新サービスの Amazon Bedrock AgentCore を使います。AgentCore の「ランタイム」という実行環境で動くエージェントが、AgentCore の「ブラウザ」ツールを使って調整ページへの記入を行います。

この記事では、Mac でローカル開発する場合を例にハンズオンの流れをざっくり紹介します。Windows や Linux の方は、パスやシェルコマンドを適宜読み替えてください。

完全なコードは、GitHubリポジトリ (https://github.com/minorun365/chosei-agent) で公開しています。本文では、手順を上から追えるようにしながら、載せるコードは主要部だけに絞っています。

アーキテクチャ図

作るもの

作る部品は、Slack の受け口、AgentCore ランタイム、Google カレンダー確認用のツールの 3 つです。

bash
Slackアプリ
  ↓
  ↓ メンション
  ↓
Lambda 関数URL
  ├─ Slack署名検証
  ├─ チーム / チャンネル確認
  └─ Slackスレッド返信
  ↓
  ↓ エージェント呼び出し
  ↓
AgentCoreランタイム
  ├─ Strands Agent
  ├─ AgentCoreブラウザ
  └─ Googleカレンダー確認ツール

Slack 連携に AWS Lambda を中間層として置く理由

Slack から AgentCore ランタイムを直接呼ばず、間に AWS Lambda を置きます。Slack には URL verification や署名検証があり、この処理をランタイム本体へ混ぜると、Slack 対応のコードと日程調整のコードがすぐ絡まってしまうためです。

Lambda は Slack の受け口だけを担当します。Slack の署名を検証し、Slack スレッドから session_id を作り、本文をランタイムの prompt に渡します。候補日の読み取り、Google カレンダー確認、入力するか聞き返すかの判断はランタイム側で行います。

なお、AWS のサーバーレスサービスのみを使っているため、少額の従量課金で試すことができます。何度か動かす程度であれば数十円〜数百円レベルから利用できるため手軽です。かつ、料金の大部分は Amazon Bedrock の API (Anthropic Claude モデルの呼び出し) が占める形となるはずです。 料金についてはご自身の責任でハンズオンを実施いただくようお願いします。 

ローカル環境を用意する

この記事では Mac でローカル開発します。Windows や Linux の方は、パスやシェルコマンドを読み替えてください。 以下を準備してから、次の手順に進みましょう。

  • AWS アカウントの作成
  • Amazon Bedrock のプレイグラウンドから Claude モデルの初回呼び出し
  • AWS CLI v2 の最新バージョン (インストール手順)
  • Node.js と npm (v22 以上)
  • Docker Desktop など、Docker イメージをビルドできる環境。
  • Python 3.10 以上
  • VS Code などのコードエディタ

AWS のリージョンは東京 (ap-northeast-1) を使います。

プロジェクトを作る

作業ディレクトリの作成

まず作業ディレクトリを作ります。

bash
mkdir chosei-agent
cd chosei-agent
mkdir -p agent lambda cdk

ファイルを作成

今回はなるべくコードをシンプルにして理解しやすくするため、 cdk init コマンドは使わずに直接ファイルを作成していきましょう。

bash
chosei-agent/
├── cdk.json
├── package.json
├── tsconfig.json
├── cdk/  # AWS CDKのインフラコード
│   └── cdk.ts
├── lambda/  # Slackとエージェントを繋ぐLambda関数
│   └── index.ts
└── agent/  # Strandsのエージェント本体
    ├── calendar_tool.py
    ├── Dockerfile
    ├── main.py
    └── requirements.txt

開発環境のセットアップ

ここでは、 agent/ を AgentCore ランタイム上にコンテナとしてデプロイし、lambda/ を Slack からの入口として置く、という関係だけ押さえてください。 Node.js 側の依存関係をインストールしましょう。

bash
npm init -y
npm install aws-cdk-lib constructs @aws-sdk/client-bedrock-agentcore @aws-sdk/client-lambda
npm install -D aws-cdk typescript @types/node esbuild

package.json

package.json の scripts は、この記事ではこの 2 つだけ使います。

json
{
  "scripts": {
    "build": "tsc",
    "cdk": "npm run build && cdk"
  }
}

cdk.json

cdk.json には、CDK アプリの入口を指定します。

json
{
  "app": "node dist/cdk/cdk.js"
}

ランタイムを作る

まずエージェント本体を置く agent/ から作ります。Slack から届いたテキストを受け取り、Strands Agents に渡す部分です。 agent/requirements.txt に必要な Python パッケージを書きます。

aws-opentelemetry-distro
bedrock-agentcore
nest-asyncio
playwright
strands-agents
strands-agents-tools

Dockerfile を作成

AgentCore ランタイムにデプロイするエージェントのコンテナ定義を agent/Dockerfile として作成します。 ADOT (AWS Distro for OpenTelemetry) を有効にしているので、デプロイしたエージェントの動作トレースが Amazon CloudWatch から監視できるようになります。

bash
FROM public.ecr.aws/docker/library/python:3.13-slim

WORKDIR /app
ENV PYTHONUNBUFFERED=1
RUN useradd -m -u 1000 bedrock_agentcore

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY --chown=bedrock_agentcore:bedrock_agentcore . .
EXPOSE 8080
USER bedrock_agentcore
CMD ["opentelemetry-instrument", "python", "main.py"]

Googleカレンダーを見る処理

Google カレンダーを見る処理は agent/calendar_tool.py に分けます。ランタイムの入口と外部 API の処理を分けておくと、あとで読み返したときに迷いにくくなります。ここでは候補日時の配列を受け取り、それぞれについて ○ / × を返す check_availability を作ります。エージェントには、候補開始時刻を日本時間の形式にしてからこのツールを呼ぶように伝えます。

python
from __future__ import annotations

import json
import os
import urllib.parse
import urllib.request
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo

from strands import tool

JST = ZoneInfo("Asia/Tokyo")


# GoogleカレンダーAPI用のアクセストークンを取得する
def google_access_token() -> str:
    body = urllib.parse.urlencode(
        {
            "client_id": os.environ["GOOGLE_CLIENT_ID"],
            "client_secret": os.environ["GOOGLE_CLIENT_SECRET"],
            "refresh_token": os.environ["GOOGLE_REFRESH_TOKEN"],
            "grant_type": "refresh_token",
        }
    ).encode("utf-8")
    request = urllib.request.Request(
        "https://oauth2.googleapis.com/token",
        data=body,
        headers={"Content-Type": "application/x-www-form-urlencoded"},
        method="POST",
    )
    with urllib.request.urlopen(request, timeout=20) as response:
        return json.loads(response.read().decode("utf-8"))["access_token"]


# ISO 8601の日時をJST基準にそろえる
def parse_jst(value: str) -> datetime:
    parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=JST)
    return parsed.astimezone(JST)


# Googleカレンダーから指定範囲に重なる予定を取得する
def calendar_events(time_min: datetime, time_max: datetime) -> list[tuple[datetime, datetime]]:
    calendar_id = urllib.parse.quote(os.getenv("GOOGLE_CALENDAR_ID", "primary"), safe="")
    query = urllib.parse.urlencode(
        {
            "timeMin": time_min.isoformat(),
            "timeMax": time_max.isoformat(),
            "singleEvents": "true",
            "orderBy": "startTime",
            "timeZone": "Asia/Tokyo",
        }
    )
    request = urllib.request.Request(
        f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events?{query}",
        headers={"Authorization": f"Bearer {google_access_token()}"},
    )
    with urllib.request.urlopen(request, timeout=20) as response:
        data = json.loads(response.read().decode("utf-8"))

    events = []
    for item in data.get("items", []):
        start = item.get("start", {}).get("dateTime") or item.get("start", {}).get("date")
        end = item.get("end", {}).get("dateTime") or item.get("end", {}).get("date")
        if item.get("status") != "cancelled" and item.get("transparency") != "transparent" and start and end:
            events.append((parse_jst(start), parse_jst(end)))
    return events


# 候補日時ごとの空き状況をJSTで判定する
@tool
def check_availability(candidates: list[str], duration_minutes: int = 60) -> list[dict[str, str]]:
    """候補日時ごとの予定有無を確認する

    Args:
        candidates: 候補開始時刻の配列。必ずJSTのISO 8601形式で指定する
        duration_minutes: 各候補の長さ。終了時刻がページにない場合は60分で確認する
    """

    slots = []
    for candidate in candidates:
        start = parse_jst(candidate)
        slots.append((candidate, start, start + timedelta(minutes=duration_minutes)))

    if not slots:
        return []

    events = calendar_events(min(slot[1] for slot in slots), max(slot[2] for slot in slots))
    results = []
    for original, start, end in slots:
        busy_count = sum(1 for event_start, event_end in events if start < event_end and event_start < end)
        results.append(
            {
                "candidate": original,
                "start_jst": start.strftime("%Y-%m-%d %H:%M"),
                "end_jst": end.strftime("%Y-%m-%d %H:%M"),
                "availability": "予定あり" if busy_count else "予定なし",
                "answer": "×" if busy_count else "○",
                "busy_count": str(busy_count),
            }
        )
    return results

Google カレンダー API から予定タイトルを取らない理由

Google カレンダー API から予定タイトルは取りません。ツールの返却値も 予定あり / 予定なし と ○ / × だけにしています。セキュリティに配慮して、Slack に予定名を出さないためです。

agent/main.py を作成

次に、メインのエージェントAPIとなる agent/main.py を作ります。Slack から届いた本文を prompt として受け取り、Strands Agent に渡すファイルです。

python
from __future__ import annotations

import os
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo

from bedrock_agentcore import BedrockAgentCoreApp
from calendar_tool import check_availability
from strands import Agent
from strands.models import BedrockModel
from strands_tools.browser import AgentCoreBrowser

app = BedrockAgentCoreApp()

REGION = os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") or "ap-northeast-1"
MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "jp.anthropic.claude-sonnet-4-6")
DISPLAY_NAME = os.getenv("SCHEDULING_DISPLAY_NAME")
JST = ZoneInfo("Asia/Tokyo")


# エージェントを作成する関数
def create_agent() -> Agent:
    today = datetime.now(JST).date().isoformat()
    system_prompt = f"""
あなたは日程調整ページの代理入力エージェントです。今日の日付は {today}、タイムゾーンはAsia/Tokyoです。
BrowserでURLを開き、候補日時を読み取り、候補開始時刻をJSTのISO 8601形式に変換してcheck_availabilityで予定を確認してください。
check_availabilityのanswerが○なら参加可能、×なら予定ありとして入力し、表示名「{DISPLAY_NAME}」で送信してください。
カレンダーの予定名はユーザーに出さず、予定あり/なしだけで扱ってください。
あなたの返答はSlackに表示されるので、太字や表などのマークダウンは使わず、見やすいプレーンテキストで返信してください。
"""

    browser = AgentCoreBrowser()
    return Agent(
        model=BedrockModel(region_name=REGION, model_id=MODEL_ID),
        system_prompt=system_prompt,
        tools=[browser.browser, check_availability],
    )


# AgentCoreランタイムの入口で、Slack本文をそのままAgentへ渡す
@app.entrypoint
def invoke(payload: dict[str, Any] | None, context: Any = None) -> dict[str, str]:
    payload = payload or {}
    prompt = payload.get("prompt") or payload.get("text") or ""
    if not prompt:
        return {"message": "日程調整ページのURLを含めて依頼してください。"}

    agent = create_agent()
    result = agent(prompt)
    return {"message": str(result)}


if __name__ == "__main__":
    app.run()

create_agent() で毎回 Agent を作る理由

create_agent() で毎回 Agent を作っているのは、Slack の別スレッドの会話履歴を持ち越さないためです。

このサンプルでは、Python 側に「調整さんのこのフォーム名を探す」といったページ固有の処理を書きません。ページを開く、候補を読む、入力欄を探す、送信する、という判断は Strands Agent に任せます。 Agent がブラウザ操作を必要だと判断したときに、ツールとして渡した AgentCore ブラウザが使われます。

Slack 受信用 Lambda を作る

次に、Slack からのイベントを受ける Lambda を作ります。 Slack の Events API は、最初に url_verification を送ってきます。通常のイベントでは、 X-Slack-Signature と X-Slack-Request-Timestamp を使って署名を検証します。Lambda のコード lambda/index.ts は少し長いので、中心部分だけ載せます。

python
import {
  BedrockAgentCoreClient,
  InvokeAgentRuntimeCommand,
} from "@aws-sdk/client-bedrock-agentcore";
import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda";

const agentcore = new BedrockAgentCoreClient({
  region: process.env.AWS_REGION ?? "ap-northeast-1",
});
const lambda = new LambdaClient({
  region: process.env.AWS_REGION ?? "ap-northeast-1",
});

type WorkItem = {
  type: "run_agent";
  channel: string;
  threadTs: string;
  sessionId: string;
  text: string;
};

// Slack Events APIのapp_mentionをAgentCoreランタイムに中継する
export async function handler(event: any) {
  if (event.type === "run_agent") {
    return runAgent(event);
  }

  const bodyText = event.isBase64Encoded
    ? Buffer.from(event.body ?? "", "base64").toString("utf8")
    : event.body ?? "{}";

  if (findHeader(event.headers ?? {}, "x-slack-retry-num")) {
    return { statusCode: 200, body: "retry ignored" };
  }

  if (!verifySlackRequest(event.headers ?? {}, bodyText)) {
    return { statusCode: 401, body: "invalid signature" };
  }

  const body = JSON.parse(bodyText);
  if (body.type === "url_verification") {
    return { statusCode: 200, headers: { "content-type": "text/plain" }, body: body.challenge };
  }
  if (!matchesExpected(body.team_id, process.env.SLACK_TEAM_ID)) {
    return { statusCode: 403, body: "unexpected team" };
  }

  const slackEvent = body.event;
  if (body.type === "event_callback" && slackEvent?.type === "app_mention") {
    if (!matchesExpected(slackEvent.channel, process.env.SLACK_CHANNEL_ID)) {
      return { statusCode: 403, body: "unexpected channel" };
    }

    const threadTs = slackEvent.thread_ts ?? slackEvent.ts;
    const sessionId = `${body.team_id}_${slackEvent.channel}_${threadTs}_${slackEvent.user}`.replace(/[^A-Za-z0-9_.-]/g, "_");

    await postSlack(slackEvent.channel, threadTs, "数分かかるので、終わったらレスしますね!");
    try {
      await enqueueWork({
        type: "run_agent",
        channel: slackEvent.channel,
        threadTs,
        sessionId,
        text: slackEvent.text ?? "",
      });
    } catch (error) {
      console.error(error);
      await postSlack(slackEvent.channel, threadTs, "処理開始に失敗しました。Lambdaのログを確認してください。");
    }
  }

  return { statusCode: 200, body: "ok" };
}

// Slackへ即応答したあと、非同期呼び出しでAgentCoreランタイムを実行する
async function enqueueWork(item: WorkItem) {
  await lambda.send(
    new InvokeCommand({
      FunctionName: process.env.AWS_LAMBDA_FUNCTION_NAME,
      InvocationType: "Event",
      Payload: Buffer.from(JSON.stringify(item)),
    })
  );
}

// AgentCoreランタイムの結果をSlackスレッドへ投稿する
async function runAgent(item: WorkItem) {
  console.log("run_agent started", { sessionId: item.sessionId, channel: item.channel, threadTs: item.threadTs });
  try {
    const result = await invokeRuntime(item.sessionId, item.text);
    await postSlack(item.channel, item.threadTs, result.message ?? JSON.stringify(result));
    console.log("run_agent completed", { sessionId: item.sessionId });
  } catch (error) {
    console.error(error);
    await postSlack(item.channel, item.threadTs, "処理に失敗しました。Lambdaのログを確認してください。");
    return { ok: false };
  }

  return { ok: true };
}

// Slack本文をAgentCoreランタイムのpromptとして渡す
async function invokeRuntime(sessionId: string, prompt: string) {
  const response = await agentcore.send(
    new InvokeAgentRuntimeCommand({
      agentRuntimeArn: process.env.AGENT_RUNTIME_ARN!,
      runtimeSessionId: sessionId,
      qualifier: "DEFAULT",
      contentType: "application/json",
      accept: "application/json",
      payload: Buffer.from(JSON.stringify({ session_id: sessionId, prompt })),
    })
  );
  const text = await responseBodyText(response.response);
  console.log("agent_runtime response received", { sessionId, bytes: Buffer.byteLength(text, "utf8") });
  return JSON.parse(text);
}

// ... responseBodyText、postSlack、verifySlackRequestなどは省略

Slack 受信用 Lambda の処理内容

enqueueWork では、同じ Lambda を非同期に呼び出しています。Slack には先に200 OKを返し、時間のかかる AgentCore ランタイム呼び出しは後続処理に回します。 署名検証、Slack への投稿、AgentCore ランタイムのレスポンス変換、ヘッダー取得、チーム / チャンネルの照合は、GitHub に完全版のコードを置いています。

Slack へは、イベント受信側から素早い 2xx 応答を返す必要があります (3 秒ルール)。今回は同じ Lambda を非同期に呼び直すことで、Amazon SQS などを増やさずにその制約へ対応しています。 

CDK でまとめてデプロイする

CDK では、AgentCore ランタイムと Slack 受信用 Lambda を同じスタックに置きます。 cdk/cdk.ts の役割は 4 つです。

  • agent/ を AgentCore ランタイムとしてデプロイする
  • lambda/index.ts を Lambda 関数 URL として公開する
  • Lambda からランタイムを呼べる IAM 権限を付ける
  • Lambda が自分自身を非同期に呼べる IAM 権限を付ける

cdk/cdk.ts

主要部分を抜き出すと、次のようになります。

typescript
// ... importや環境変数チェック関数は省略

// AgentCoreランタイムとSlack受信用Lambdaをデプロイする
class ChoseiAgentStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // agent/ をAgentCoreランタイムのコンテナとしてデプロイする
    const runtime = new Runtime(this, 'Runtime', {
      runtimeName: 'ChoseiAgent',
      agentRuntimeArtifact: AgentRuntimeArtifact.fromAsset(path.join(__dirname, '/agent')),
      protocolConfiguration: ProtocolType.HTTP,
      environmentVariables: runtimeEnvironment(Stack.of(this).region),
    });

    // AgentCore Browser Toolを使うため、検証ではランタイムへ広めのAgentCore権限を付ける
    runtime.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['bedrock-agentcore:*'],
        resources: ['*'],
      })
    );

    // Strands AgentからBedrockモデルを呼び出すための権限を付ける
    runtime.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream'],
        resources: ['arn:aws:bedrock:*::foundation-model/*', 'arn:aws:bedrock:*:*:inference-profile/*'],
      })
    );

    runtime.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['aws-marketplace:Subscribe', 'aws-marketplace:ViewSubscriptions', 'aws-marketplace:Unsubscribe'],
        resources: ['*'],
        conditions: {
          StringEquals: {
            'aws:CalledViaLast': 'bedrock.amazonaws.com',
          },
        },
      })
    );

    // lambda/index.tsをSlack Events APIの受け口として公開する
    const slackAdapter = new NodejsFunction(this, 'SlackAdapter', {
      runtime: lambda.Runtime.NODEJS_22_X,
      entry: path.join(__dirname, '/lambda', 'index.ts'),
      projectRoot: path.join(__dirname, '/'),
      depsLockFilePath: path.join(__dirname, '/package-lock.json'),
      handler: 'handler',
      timeout: Duration.minutes(10),
      environment: lambdaEnvironment(runtime.agentRuntimeArn),
      bundling: {
        externalModules: [],
      },
    });

    // Slack受信用LambdaからAgentCoreランタイムを呼べるようにする
    slackAdapter.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['bedrock-agentcore:InvokeAgentRuntime'],
        resources: [runtime.agentRuntimeArn, `${runtime.agentRuntimeArn}/runtime-endpoint/*`],
      })
    );
    // Slackへ即応答したあと、同じLambdaを非同期に呼び出してAgent処理を続ける
    slackAdapter.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ['lambda:InvokeFunction'],
        resources: [
          Stack.of(this).formatArn({
            arnFormat: ArnFormat.COLON_RESOURCE_NAME,
            service: 'lambda',
            resource: 'function',
            resourceName: `${Stack.of(this).stackName}-SlackAdapter*`,
          }),
        ],
      })
    );

    // Slack AppのEvent Subscriptionsに登録するHTTPSエンドポイントを作る
    const slackAdapterUrl = slackAdapter.addFunctionUrl({
      authType: lambda.FunctionUrlAuthType.NONE,
    });

    // ... CfnOutputやapp作成は省略
  }
}

実際の cdk/cdk.ts について

実際の cdk/cdk.ts には、このほかに必須環境変数を確認する関数、ランタイム / Lambda へ渡す環境変数を組み立てる関数、CloudFormation Output なども入っています。 Amazon CloudWatch へトレースを送るため、ランタイム側には AGENT_OBSERVABILITY_ENABLEDOTEL_PYTHON_DISTROOTEL_PYTHON_CONFIGURATOROTEL_EXPORTER_OTLP_PROTOCOL も渡しています。

検証を短く進めるため、AgentCore ブラウザ関連のランタイム権限は広めにしています。本番に近づけるときは、AWS CloudTrail や実行ログを見ながら必要なアクションに絞ってください。

Google カレンダーの認証情報を用意する

ここから AWS 以外の、外部サービスの設定に移ります。 Google カレンダーは予定の読み取りだけに使います。Google Cloud コンソールで設定をしましょう。

細かい画面操作は Google カレンダー API のドキュメント (https://developers.google.com/workspace/calendar/api/quickstart/python) を参照してください。ここでは、今回必要な作業だけを書きます。

  • Google Cloud コンソールで検証用プロジェクトを作る、または既存プロジェクトを選びます。
  • Googleカレンダー API を有効化します。
  • Google Auth platform の Branding を最低限設定します。
  • OAuth client を作ります。Application type は Desktop app を選びます。
  • credentials.json をダウンロードします。
  • Google カレンダー API の Python Quickstart と同じ流れで認可し、 token.json を作ります。

個人の Google アカウントで試す場合は、OAuth 同意画面をテスト公開のままにして、自分の Google アカウントをテストユーザーに入れておけば進められます。組織の Google Workspace で試す場合は、組織のポリシーに従ってください。 使う scope は読み取り専用で OK です。

https://www.googleapis.com/auth/calendar.readonly

ここで使う JSON は 2 つあります。 credentials.json は OAuth クライアントの情報で、Google Cloud コンソールからダウンロードするファイルです。 token.json は Quickstart の認可を実行したあとにローカルで作られる、ユーザー認可結果のファイルです。 いずれも機密情報を含むため、漏洩しないように注意して扱い、GitHub へも絶対にコミットしないでください。後で環境変数に入れる値は、それぞれ次の場所から控えます。

  • GOOGLE_CLIENT_ID : credentials.json installed.client_id
  • GOOGLE_CLIENT_SECRET : credentials.jsoninstalled.client_secret
  • GOOGLE_REFRESH_TOKEN : token.json refresh_token

Slack App を作る

Slack 側では、Bot がメンションされたときだけ Lambda にイベントを送る App を作ります。

Slack API の Your Apps 画面 (https://api.slack.com/apps) から、検証用 workspace に App を作ります。イベント設定の考え方は Slack Events API のドキュメント (https://docs.slack.dev/apis/events-api/) も参照してください。最初に触るのは次の項目です。

  1. App を From scratch で作ります。
  2. OAuth & Permissions で Bot Token Scopes に app_mentions:read chat:write を追加します。
  3. App を workspace にインストールし、Bot User OAuth Token を控えます。
  4. Basic Information で Signing Secret を控えます。
  5. 投稿先にしたい Slack チャンネルへ Bot を招待します。

Event Subscriptions の Request URL は、CDK デプロイ後に出力される Lambda 関数 URL を入れます。この時点では、まだ空のままで構いません。控える値は以下の 4 つです。

  • SLACK_SIGNING_SECRET
  • SLACK_BOT_TOKEN
  • SLACK_TEAM_ID
  • SLACK_CHANNEL_ID

SLACK_TEAM_ID の呼び出し

SLACK_TEAM_ID は、以下のようにBot Tokenで auth.test を呼ぶと確認できます。

bash
curl -H "Authorization: Bearer $SLACK_BOT_TOKEN" https://slack.com/api/auth.test

SLACK_CHANNEL_ID の確認

SLACK_CHANNEL_ID は、Slack のチャンネル詳細やチャンネルリンクから確認できます。必須ではありませんが、検証中は指定しておくのがおすすめです。想定外のチャンネルで Bot が反応するのを防げます。

デプロイする

AWS CLI は aws login で認証します。ブラウザが開いたら、デプロイ先にしたい AWS アカウントでログインしてください。

bash
aws login
aws sts get-caller-identity

Account とデプロイ先の確認

複数の AWS アカウントを使っている場合は、ここで表示される Account がデプロイ先と一致しているか確認してください。その後、必要な値を作業 PC のターミナルで環境変数に入れます。... の部分は、それぞれ自分の値に置き換えてください。

bash
export AWS_REGION="ap-northeast-1"

export GOOGLE_CLIENT_ID="..."
export GOOGLE_CLIENT_SECRET="..."
export GOOGLE_REFRESH_TOKEN="..."
export GOOGLE_CALENDAR_ID="primary"

# 日程調整ページに入力する自分の名前
export SCHEDULING_DISPLAY_NAME="..."

export SLACK_SIGNING_SECRET="..."
export SLACK_BOT_TOKEN="xoxb-..."
export SLACK_TEAM_ID="T..."
export SLACK_CHANNEL_ID="C..."

注意

今回は簡易ハンズオンのため、Google や Slack のシークレットを環境変数で渡しています。本番では AWS Secrets Manager や AWS Systems Manager Parameter Store の SecureString に置き、実行時に取得する構成にしてください。

bootstrap を実行

初めてその AWS アカウントとリージョンで CDK を使う場合は、bootstrap を実行します。

bash
npx cdk bootstrap

デプロイする

続けて、ビルドしてデプロイします。

bash
npm run build
npx cdk deploy

URL を Request URL へ設定

デプロイが終わると、 SlackAdapterUrl が出力されます。この URL を Slack App の Event Subscriptions にある Request URL へ設定します。 Slack 側で URL verification が成功したら、Bot Events に app_mention を追加し、App を再インストールします。

動かしてみる

Slack の投稿先チャンネルに Bot を招待してから、日程調整ページの URL を投げます。

bash
@chosei-agent
https://example.com/schedule/...

成功 !

最初に「数分かかるので、終わったらレスしますね!」と返ってくれば、Slack から Lambda までは届いています。 その後、ランタイム上の Strands Agent が Browser Tool と check_availability を使って結果を返します。

おわりに

うまく動かない場合は、GitHub の Issues (https://github.com/minorun365/chosei-agent/issues) へ投稿いただければ、ベストエフォートで解決のお手伝いをさせていただきます。

AgentCore については、AWS コミュニティの仲間と入門書を先日出版しましたので、もっと学びたい方はぜひお手に取ってみてください ! 

Amazon Bedrock AgentCore 実践入門 Strands Agentsで構築するAIエージェント [AWS深掘りガイド]

筆者プロフィール

御田 稔 (みのるん @minorun365)

KDDIアジャイル開発センター株式会社 テックエバンジェリスト。クラウドや AI を用いた開発を行いながら、技術の楽しさを広める活動をしています。

AWS AI Hero、AWS Samurai 2023/2024 認定。著書「Amazon Bedrock 生成 AI アプリ開発入門」(SBクリエイティブ刊)、「やさしい MCP 入門」、「AI エージェント開発/運用入門」(SBクリエイティブ刊)

A person wearing glasses and a white t-shirt, crossing their arms and smiling, standing against a plain background.