Streamlit公式のAgent Skillsを使ってAmazon BedrockのチャットUIをClaude Codeに作らせてみた

Streamlit公式のAgent Skillsを使ってAmazon BedrockのチャットUIをClaude Codeに作らせてみた

2026.05.19

はじめに

みなさんこんにちは、クラウド事業本部コンサルティング部の浅野です。

Streamlitから公式のAgent Skills集が公開されています。これはStreamlitアプリ開発のノウハウをスキルとしてまとめたもので、Claude Codeなどのコーディングエージェントに読み込ませることで、Streamlitのお作法に沿ったアプリをエージェントに作らせることができます。

今回は、このStreamlit公式Agent Skillsだけを頼りに、Claude CodeでAmazon BedrockのチャットUIアプリをゼロから作らせてみました。スキルが実際に呼び出されて開発が進む様子を、ビルドログと実際のWebアプリ画面のスクリーンショットを貼りながら使用感を確かめます。

Streamlit公式Agent Skills

Streamlit公式のAgent Skillsは、以下のGitHubリポジトリで公開されています。

https://github.com/streamlit/agent-skills

ライセンスはApache License 2.0です。検証記事での紹介やスキル本文の一部引用や紹介が可能です。

構成の変更

いきなりですが、この公式Skillは、2026/05/13以降にリポジトリの構成が大きく変更された経緯があります。
記事の前提として、変更前後で何が変わったかを押さえておきます。

以前は、以下のように「メインのルーティングスキル + 全サブスキル + アプリ/テーマのテンプレート一式」が、リポジトリの.claude/skills/配下に丸ごと物理的に同梱される形式でした。

.claude/skills/developing-with-streamlit/
├── SKILL.md                                 # 入口(ルーティング)
├── building-streamlit-chat-ui/
   └── SKILL.md
├── building-streamlit-custom-components-v2/
   ├── SKILL.md
   └── references/                          # サブスキルが自前のリファレンスを保持
       ├── packaged-components.md
       ├── state-sync.md
       ├── theme-css-variables.md
       └── troubleshooting.md
├── building-streamlit-dashboards/SKILL.md
├── creating-streamlit-themes/SKILL.md
├── setting-up-streamlit-environment/SKILL.md
├── …(サブスキル全17種)
└── templates/                               # サンプルアプリ・テーマも同梱
    ├── apps/
   ├── dashboard-companies/streamlit_app.py
   ├── dashboard-metrics/streamlit_app.py
   └── …(dashboard-* 多数)
    └── themes/
        ├── dracula/.streamlit/config.toml
        ├── nord/…
        └── …(テーマ一式)

2026/05/13の変更以後は、.claude/skills/に置くのは2ファイル(SKILL.mddiscover.py)だけになりました。

.claude/skills/developing-with-streamlit/
├── SKILL.md          … 「discover.pyを実行せよ」と記載しているだけ
└── scripts/discover.py … インストール済みStreamlitパッケージ内の本体を探すだけ

SKILL.mdにはAIエージェント向けに「discover.pyを実行せよ」と書いてあるだけで、ドキュメント本体を探す処理はdiscover.pyが担います。実行するとimport streamlitでインストール済みのStreamlitパッケージの場所を特定し、そのバージョンに同梱されたスキル本体のパスを返す、という流れです。

元々同梱されていたテンプレートやスキルのドキュメント本体はスキルのリポジトリではなく、Streamlit本体のパッケージ(PyPIで配布されるStreamlit本体, v1.57以上)に同梱されるようになりました。discover.pyが返すのはこの同梱本体のSKILL.mdで、そこからさらにreferences/配下の各リファレンスへ誘導されるようになりました。

同梱本体は、インストールしたStreamlitパッケージ内に以下のような形式で入っています(一部省略)。

<venv>/.../site-packages/streamlit/.agents/skills/developing-with-streamlit/
├── SKILL.md            # 本体のルーティング。タスク種別に応じ references/ へ誘導
└── references/
    ├── chat-ui.md
    ├── custom-components-v2.md
    ├── ccv2-state-sync.md
    ├── theme.md
    ├── layouts.md
    ├── environment-setup.md
    └── …(全21本)

references/には例えば以下のような21本のドキュメントが入っており、AIエージェントがStreamlitのベストプラクティスに従いやすいように構成されています。

リファレンス 用途 今回使用
chat-ui チャット/会話UIの構築 ⚪︎
custom-components-v2 双方向カスタムコンポーネント(CCv2)の作成 ⚪︎
ccv2-state-sync CCv2のフロント⇔Python状態同期
ccv2-theme-css-variables CCv2のテーマCSS変数 ⚪︎
ccv2-packaged-components CCv2のパッケージ化
ccv2-troubleshooting CCv2のトラブルシュート
theme config.tomlによるテーマ作成 ⚪︎
layouts サイドバー/カラム/コンテナ等のレイアウト
environment-setup uv等での環境セットアップ
dashboards ダッシュボード/KPI表示
data-display チャート/データフレーム/メトリクス表示
design アイコン/バッジ/余白などの見た目改善
performance キャッシュ/フラグメントによる最適化
code-organization コードのモジュール分割と整理
multipage-apps マルチページアプリ/ナビゲーション
selection-widgets 選択ウィジェットの使い分け
session-state st.session_stateによる状態管理 ⚪︎
markdown Markdown記法/色付き文字/アイコン
cli Streamlit CLIの利用
third-party-components サードパーティ製カスタムコンポーネント
snowflake-connection Snowflakeとの接続

以前ではStreamlit本体のパッケージ更新に加え、スキルのアップデートも行わなくてはなりませんでしたが、2026/05/13以降の構成変更によりスキルを個別に更新しなくても、Streamlitを最新バージョンへ上げるだけでスキルの参照もランタイムに合わせて最新化されるようになりました。リポジトリに抱えるのも2ファイルだけで済むようになっています。

やってみた

検証で作るアプリの要件は以下です。

  • Amazon BedrockのモデルをチャットUIで呼び出し、回答をストリーミング表示する
  • サイドバーに会話履歴クリアボタンと、利用可能なモデル/リージョンのセレクタを置く
  • 白&緑のライトテーマ、黒&青のダークテーマを切り替えられるカスタムテーマ
  • 回答メッセージの下に「コピー」「👍/👎」フィードバックボタンを付ける
  • サイドバーにシステムプロンプトの入力欄を追加する
  • boto3でローカル認証情報を使い、Bedrockへ実接続する

これらを、段階的に依頼しながらエージェントに作らせていきます。

検証環境は以下のとおりです。

項目 バージョン
Claude Code 2.1.144
使用モデル Claude Opus 4.7
OS macOS (Apple Silicon)
Python 3.13.5
uv 0.8.3
Streamlit 1.57.0
boto3 / botocore 1.43.9

Streamlit環境のセットアップ

スキルを入れる前に、まずStreamlit本体を用意します。前述のとおりスキルの参照ドキュメントはStreamlit(v1.57以上)のパッケージに同梱されるため、これを満たす環境がないとスキルが機能しません。今回はuvで構築しました。

uv init --bare --python 3.13
uv add streamlit
uv run streamlit --version

出力(抜粋):

Initialized project `demo-agent-streamlit`
...
Creating virtual environment at: .venv
Installed 41 packages in 111ms
...
 + streamlit==1.57.0
...
Streamlit, version 1.57.0

.venvuv.lockが生成され、Streamlit 1.57.0が入りました。

スキルのインストール

今回はClaude Codeを使用する前提です。インストールはとても簡単で、リポジトリをcloneしてスキルフォルダをプロジェクトの.claude/skills/にコピーするだけです。今回はプロジェクトスコープでSkillを入れたかったので、プロジェクト直下の.claude/skills/に置いてます。

git clone https://github.com/streamlit/agent-skills.git
cp -r agent-skills/developing-with-streamlit demo-agent-streamlit/.claude/skills/

配置後のディレクトリ構造は以下のようになりました。

.
├── .claude
│   ├── settings.local.json
│   └── skills
│       └── developing-with-streamlit
│           ├── scripts
│           └── SKILL.md
├── .venv
│   ├── .gitignore
│   ├── .lock
│   ├── bin
│   │   ├── activate
│   │   ├── activate_this.py
│   │   ├── activate.bat
│   │   ├── activate.csh
│   │   ├── activate.fish
│   │   ├── activate.nu
│   │   ├── activate.ps1
│   │   ├── deactivate.bat
│   │   ├── f2py
│   │   ├── jsonschema
│   │   ├── normalizer
│   │   ├── numpy-config
│   │   ├── pydoc.bat
│   │   ├── python -> /opt/homebrew/opt/python@3.13/bin/python3.13
│   │   ├── python3 -> python
│   │   ├── python3.13 -> python
│   │   ├── streamlit
│   │   ├── uvicorn
│   │   └── websockets
│   ├── CACHEDIR.TAG
│   ├── etc
│   │   └── jupyter
│   ├── lib
│   │   └── python3.13
│   ├── pyvenv.cfg
│   └── share
│       └── jupyter
├── pyproject.toml
└── uv.lock

入り口の.claude/skills/developing-with-streamlit/SKILL.mdは以下のようになっており、要約するとscripts/discover.pyを実行してくれと記載してあります。

---
name: developing-with-streamlit
description: "Use for ALL Streamlit tasks: creating, editing, debugging, beautifying, styling, theming, optimizing, or deploying Streamlit apps. Also custom components, st.components.v2, HTML/JS/CSS work. Discovers and loads version-matched reference docs from the user's installed Streamlit (>=1.57). Triggers: streamlit, st., dashboard, app.py, beautify, style, CSS, color, background, theme, button, widget styling, custom component, st.components, CCv2, session state, performance, cache, fragment, slow rerun, deploy."
allowed-tools: Bash(python ${CLAUDE_SKILL_DIR}/scripts/discover.py:*) Bash(python3 ${CLAUDE_SKILL_DIR}/scripts/discover.py:*)
---

# Developing with Streamlit

Streamlit (>=1.57) ships detailed reference documentation for building Streamlit apps inside its pip package. The bundled skill is a routing `SKILL.md` plus a `references/` folder of topic-specific reference docs (dashboards, themes, layouts, session state, custom components, etc.).

## Usage

Run the discovery script with the user's project directory:


`python <SKILL_DIR>/scripts/discover.py --project-dir <USER_PROJECT_DIR>`

The script prints either:

- **A path on stdout** (exit 0) — the bundled `SKILL.md`. Read it; it points into `references/`.
- **An `ERROR:` block on stderr** (non-zero exit). Follow the printed instructions and re-run.

`<SKILL_DIR>` is the directory containing this file; `<USER_PROJECT_DIR>` is the absolute path to the user's project. Passing `--project-dir` matters because the script resolves `.venv`, `../.venv`, `Pipfile`, `poetry.lock`, `pdm.lock`, and `uv.lock` relative to it.

実際にdiscover.pyの中身を確認すると、処理は次の流れになっています。

→ discover.py 実行(環境検出 → import streamlit でパッケージ位置を特定)
→ 返ってきたパスの「Streamlit同梱 SKILL.md」を読む
→ タスク種別に応じて references/ の該当ドキュメントへ誘導
→ その内容に沿ってアプリ実装

このような二段構成で参照されるナレッジは常にインストール済みStreamlitのバージョンに同梱されたものになるため、ランタイムとSkillドキュメントのバージョンずれが起きない、という仕組みです。

チャットUI作成

では実際にSkillを使ってみましょう。まず以下の要件でWebアプリのモック作成を依頼しました。

Streamlit公式のAgent Skillsを使用して以下のアプリを作成したい
  - AWS BedrockのモデルをチャットUI画面
  - 左側のサイドバーに会話履歴クリアボタン
  - 中央にチャット画面でストリーミング方式で回答
  - Bedrock接続はいったん考えず、回答はモックを用意
  - サイドバーに推論可能なモデルと場所をセレクタで表示(最初はモックでよい)

ビルドログを見ると、依頼直後にdeveloping-with-streamlitスキルが読み込まれ、その指示どおりdiscover.pyが実行されています。

⏺ Skill(developing-with-streamlit)
  ⎿  Successfully loaded skill · 2 tools allowed

⏺ Bash(python <project>/.claude/skills/developing-with-streamlit/scripts/discover.py --project-dir <project>)
  ⎿  Error: Exit code 127
     (eval):1: command not found: python

⏺ Bash(python3 <project>/.claude/skills/developing-with-streamlit/scripts/discover.py --project-dir <project>)
  ⎿  <project>/.venv/lib/python3.13/site-packages/streamlit/.agents/skills/developing-with-streamlit/SKILL.md

⏺ Read(.venv/.../streamlit/.agents/skills/developing-with-streamlit/SKILL.md)
⏺ Read(.venv/.../streamlit/.agents/skills/developing-with-streamlit/references/chat-ui.md)

⏺ Write(streamlit_app.py)
  ⎿  Wrote 129 lines to streamlit_app.py

discover.pyが返した同梱SKILL.mdを読み、そこから誘導されたreferences/chat-ui.mdを参照したうえでstreamlit_app.pyが生成されています。

エージェントの完了報告でも、developing-with-streamlitchat-uiリファレンスの推奨パターンに沿って実装した旨が明記されていました。

streamlit_app.py を作成しました。Streamlit公式スキル
(developing-with-streamlit → chat-ui リファレンス)の推奨パターンに沿っています。

実装内容:
- サイドバー: 推論モデルのセレクタ(Claude Opus 4.7 / Sonnet 4.6 / Haiku 4.5
  / Amazon Nova Pro / Llama 3.3 — モック)、選択モデルが対応するリージョンのみ
  表示する連動セレクタ、「会話履歴をクリア」ボタン(履歴が空のとき無効化)
- メイン: st.chat_message / st.chat_input、st.write_stream でモック回答を
  トークンごとにストリーミング表示
- Bedrock 接続: 未実装。mock_stream_response() を後から
  bedrock-runtime の InvokeModelWithResponseStream に差し替え可能な構造

出力できたモック画面がこちらです。サイドバーにリージョン/モデルセレクタと会話履歴クリアボタン、中央にチャット、回答はモックがストリーミング表示されるようにできました。この指示だけでなかなかの完成度ですね。

2026-05-19-streamlit-agent-skills-bedrock-chat-02

要件どおり、Bedrock未接続のままフロント画面とモック応答が一発で組み上がりました。

テーマ&カスタムコンポーネントの適用

次に、テーマとフィードバック用カスタムコンポーネントの追加を依頼しました。

Skillを使用して以下の追加依頼を実行して
  - カスタムテーマとして白&緑(アクセント)を基調としてライトテーマ
    と黒&青(アクセント)を基調としたダークモードをテーマで構成して
  - 回答メッセージの下に「コピー」と「👍/👎」のフィードバックボタン
    をカスタムコンポーネントで作成したい

ここでも呼ばれるスキルはdeveloping-with-streamlitの1本だけです。discover.pyが再度実行され、今度はタスクに応じてtheme.md / custom-components-v2.md / ccv2-theme-css-variables.mdの3つのリファレンスが参照されています。

⏺ Skill(developing-with-streamlit)
  ⎿  Successfully loaded skill · 2 tools allowed

⏺ Bash(python3 <project>/.claude/skills/developing-with-streamlit/scripts/discover.py --project-dir <project>)
  ⎿  <project>/.venv/lib/python3.13/site-packages/streamlit/.agents/skills/developing-with-streamlit/SKILL.md

  Read 1 file, listed 1 directory (ctrl+o to expand)

⏺ スキル再読み込み・リファレンス確認完了(theme.md /
  custom-components-v2.md / ccv2-theme-css-variables.md)。--st-* 変数名も確認しました。

⏺ Write(.streamlit/config.toml)
  ⎿  Wrote 52 lines to .streamlit/config.toml
⏺ Write(streamlit_app.py)
  ⎿  Added 123 lines, removed 8 lines

theme.mdには、テーマ目的でCSSを使わないことが明記されています。

## IMPORTANT: No custom CSS unless explicitly requested

**DO NOT use custom CSS or HTML for theming.** This includes:
- `st.markdown(..., unsafe_allow_html=True)` with `<style>` or inline styles
- `st.html()` with `<style>` blocks
- Any HTML/CSS for colors, backgrounds, fonts, or visual styling

また、ライト/ダーク両方を定義すれば標準メニューで切り替え可能になることも書かれています。

## Light and dark modes

Users can switch between modes in the app settings menu only if both
`[theme.light]` and `[theme.dark]` are defined. A custom theme with just
`[theme]` locks the app to a single mode.

この指示どおり、CSSを使わず白緑(ライト)/黒青(ダーク)基調のconfig.tomlが生成されました。[theme.light][theme.dark]を両方定義しているため、設定メニューからの切り替えに追従します。

生成された .streamlit/config.toml
# Bedrock チャットデモ用カスタムテーマ
#
# ライト = 白基調 + 緑アクセント / ダーク = 黒基調 + 青アクセント
# [theme.light] と [theme.dark] を両方定義しているため、設定メニューから
# ライト/ダークを切り替えても、それぞれのカスタム配色が維持される。

[theme]
# 両モード共通の設定(色は light/dark 側で上書き)
font = "Inter:https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
baseRadius = "8px"
linkUnderline = false
showSidebarBorder = true

# ライトテーマ: 白基調 + 緑アクセント
[theme.light]
primaryColor = "#15803d"
backgroundColor = "#ffffff"
secondaryBackgroundColor = "#eef5f0"
textColor = "#1f2a24"
linkColor = "#15803d"
borderColor = "#cde2d4"
greenColor = "#15803d"
codeBackgroundColor = "#eef5f0"

[theme.light.sidebar]
backgroundColor = "#f1f7f3"
secondaryBackgroundColor = "#e2efe7"
textColor = "#1f2a24"
borderColor = "#cde2d4"
primaryColor = "#15803d"

# ダークテーマ: 黒基調 + 青アクセント
[theme.dark]
primaryColor = "#3b82f6"
backgroundColor = "#0a0e14"
secondaryBackgroundColor = "#141b24"
textColor = "#e6edf3"
linkColor = "#60a5fa"
borderColor = "#222c38"
blueColor = "#3b82f6"
codeBackgroundColor = "#141b24"

[theme.dark.sidebar]
backgroundColor = "#06090d"
secondaryBackgroundColor = "#141b24"
textColor = "#e6edf3"
borderColor = "#222c38"
primaryColor = "#3b82f6"

フィードバックボタンはcustom-components-v2.md / ccv2-theme-css-variables.mdのCCv2パターンに沿ってstreamlit_app.pyに実装されました。配色は--st-*変数を使うため白緑/黒青どちらのテーマにも自動追従します。

streamlit_app.py に追加された CCv2 コンポーネント(抜粋)
_MESSAGE_ACTIONS = st.components.v2.component(
    "message_actions",
    html=_ACTIONS_HTML,   # コピー / 👍 / 👎 ボタン
    css=_ACTIONS_CSS,     # 配色は var(--st-text-color) / var(--st-primary-color) など
    js=_ACTIONS_JS,       # copy: setTriggerValue("copied") / 評価: setStateValue("feedback")
)


def message_actions(idx, text, feedback, *, on_copied=None, on_feedback=None):
    return _MESSAGE_ACTIONS(
        key=f"msg-actions-{idx}",
        data={"text": text, "feedback": feedback},
        on_feedback_change=on_feedback or (lambda: None),
        on_copied_change=on_copied or (lambda: None),
    )

CCv2(st.components.v2)は、Streamlit 1.57で追加された双方向カスタムコンポーネントの仕組みです。HTML/CSS/JavaScriptで独自のUI部品を作り、ボタン操作などの値をPython側と双方向にやり取りできます(標準ウィジェットには無いコピーや評価ボタンを実現するのに使われています)。

エージェントは構文チェックと、この機能がインストール済みStreamlitで使えるか(st.components.v2が存在するか)まで自動で確認していました。

⎿  構文OK
   CCv2: True
   ポート850*でStreamlitアプリは未検出

適用後の画面がこちらです。白+緑のライトテーマが適用され、回答の下にコピーと評価のボタン、サイドバーに評価の集計が表示されています。

2026-05-19-streamlit-agent-skills-bedrock-chat-03

Bedrockとの接続

最後にモックを実接続へ差し替えます。Bedrock接続まわりはStreamlitのスキルではカバーされないので、あらかじめAWS公式のAgent toolkit for AWSaws-coreプラグイン)をインストールしてあります。

続いてBedrockとの接続を行います。
- モックの回答からboto3でローカル認証情報を使ってBedrock経由でAnthropicモデルを呼び出すように調整して
- モデル / 推論リージョンのセレクタは実際にアカウントで有効になっているものだけが出てくるように調整して

依頼すると、まずaws-core:amazon-bedrockスキルが起動し、SDKコード生成前に必須のリファレンス(sdk-converse-api-python.md)を読み込み、続けてaws-core:aws-sdk-python-usageも読み込まれました。

⏺ Skill(aws-core:amazon-bedrock)
⏺ Read(.../amazon-bedrock/references/sdk-converse-api-python.md)
  ⎿  Read 157 lines
⏺ Skill(aws-core:aws-sdk-python-usage)
⏺ Update(pyproject.toml)   # boto3>=1.34.0 を追加
⏺ Write(streamlit_app.py)  # モック撤廃、converse_stream 実装、モデル/リージョン動的取得
⏺ Bash(.venv/bin/python -m py_compile ... ; uv sync)
  ⎿  構文OK
     + boto3==1.43.9  + botocore==1.43.9  + jmespath==1.1.0  + s3transfer==0.17.0

スキル準拠での主な実装内容は、エージェントの完了報告で次のように説明されていました。

  • BedrockのストリーミングAPIで応答を逐次受け取る。AWS認証はローカルの認証情報(~/.awsや環境変数など)をそのまま利用
  • 出力トークンの上限を明示指定(未指定だと過大なリソース予約となり、レート制限エラーの原因になるため)
  • セレクタはAPIでアカウントを実際に問い合わせ、利用可能なリージョン/モデルだけを表示。新しめのClaudeは専用の呼び出しIDが必要なため、それも自動で解決
  • 一度取得したクライアントや検出結果は使い回し(キャッシュ)して、画面更新のたびに無駄なAPI呼び出しをしない

実装はすべてstreamlit_app.pyに統合されています。Bedrock呼び出しの中核(実ファイルからの抜粋)は以下のとおりです。

streamlit_app.py の Bedrock 呼び出し部(抜粋)
def stream_bedrock(
    region: str, model_id: str, history: list[dict], system_prompt: str = ""
) -> Iterator[str]:
    """bedrock-runtime の converse_stream を呼び出しテキスト差分を逐次 yield する。"""
    client = bedrock_runtime_client(region)
    params: dict = {
        "modelId": model_id,
        "messages": to_converse_messages(history),
        "inferenceConfig": {"maxTokens": MAX_TOKENS},
    }
    if system_prompt.strip():
        params["system"] = [{"text": system_prompt}]
    response = client.converse_stream(**params)
    for event in response["stream"]:
        if "contentBlockDelta" in event:
            yield event["contentBlockDelta"]["delta"].get("text", "")

接続後、サイドバーのセレクタにアカウントで実際に利用可能なリージョン/モデルが動的に並ぶようになりました。リージョンは候補をlist_foundation_modelsで走査して列挙できたものだけ、モデルはそのリージョンで有効なAnthropicモデルのみが表示されます。

2026-05-19-streamlit-agent-skills-bedrock-chat-04

システムプロンプト欄の追加

最後に、サイドバーへシステムプロンプト入力欄を追加依頼しました。ここでは再びdeveloping-with-streamlitが呼ばれ、discover.py経由でsession-state.mdが参照されています。

システムプロンプトを入力する欄をサイドバーに追加して

⏺ Skill(developing-with-streamlit)
⏺ Bash(python3 .../discover.py --project-dir <project>)
  ⎿  <project>/.venv/.../streamlit/.agents/skills/developing-with-streamlit/SKILL.md
⏺ Read(.venv/.../references/session-state.md)
⏺ Update(streamlit_app.py)  # st.text_area("システムプロンプト", key="system_prompt")
⏺ Update(streamlit_app.py)  # converse_stream に system=[{"text": ...}] を渡す

Streamlitは操作のたびに画面スクリプトを丸ごと再実行しますが、session-state.mdの推奨どおり入力欄に名前(キー)を付けることで、再実行をまたいで入力値が保持されます。入力されたシステムプロンプトは、空欄でなければモデルへの指示としてBedrockのリクエストに渡されます。最終的に、実際のストリーミング応答が返るところまで確認できました。

2026-05-19-streamlit-agent-skills-bedrock-chat-05

完成したアプリ構成

最終的なアプリ構成はシンプルで、ロジックはstreamlit_app.py1ファイルに集約されました。

demo-agent-streamlit/
├── .streamlit/
│   └── config.toml      # アプリ専用テーマ(ライト/ダーク両定義)
├── .claude/
│   └── skills/
│       └── developing-with-streamlit/   # 公式スキル(薄いラッパー2ファイル)
├── streamlit_app.py     # UI・CCv2・Bedrock接続をすべて含む(約450行)
├── pyproject.toml       # 依存定義(streamlit, boto3。uvで管理)
└── uv.lock

できあがったstreamlit_app.pyの全コードは以下のとおりです。

streamlit_app.py(全文)
"""AWS Bedrock チャットUI デモ

ローカル認証情報(boto3 既定のクレデンシャルチェーン)を使い、bedrock-runtime の
Converse Stream API で Anthropic モデルを呼び出してストリーミング応答する。
サイドバーのモデル / リージョンのセレクタは、アカウントで実際に利用可能なものだけを
ランタイムに検出して表示する。
"""

from collections.abc import Callable, Iterator

import boto3
import streamlit as st
from botocore.config import Config
from botocore.exceptions import (
    BotoCoreError,
    ClientError,
    NoCredentialsError,
    SSOTokenLoadError,
    TokenRetrievalError,
)

# 検出対象とする Bedrock 商用リージョンの候補。利用できないものは検出時に除外する。
CANDIDATE_REGIONS: list[str] = [
    "us-east-1",
    "us-east-2",
    "us-west-2",
    "ap-northeast-1",
    "ap-northeast-2",
    "ap-northeast-3",
    "ap-south-1",
    "ap-southeast-1",
    "ap-southeast-2",
    "ca-central-1",
    "eu-central-1",
    "eu-west-1",
    "eu-west-2",
    "eu-west-3",
    "sa-east-1",
]

# リージョンコードと表示名(場所)の対応
REGION_LABELS: dict[str, str] = {
    "us-east-1": "米国東部(バージニア北部)",
    "us-east-2": "米国東部(オハイオ)",
    "us-west-2": "米国西部(オレゴン)",
    "ap-northeast-1": "アジアパシフィック(東京)",
    "ap-northeast-2": "アジアパシフィック(ソウル)",
    "ap-northeast-3": "アジアパシフィック(大阪)",
    "ap-south-1": "アジアパシフィック(ムンバイ)",
    "ap-southeast-1": "アジアパシフィック(シンガポール)",
    "ap-southeast-2": "アジアパシフィック(シドニー)",
    "ca-central-1": "カナダ(中部)",
    "eu-central-1": "欧州(フランクフルト)",
    "eu-west-1": "欧州(アイルランド)",
    "eu-west-2": "欧州(ロンドン)",
    "eu-west-3": "欧州(パリ)",
    "sa-east-1": "南米(サンパウロ)",
}

# 認証情報そのものが見つからない / 失効している場合に送出される例外
CREDENTIAL_ERRORS = (NoCredentialsError, TokenRetrievalError, SSOTokenLoadError)

# 制御プレーン(モデル一覧の検出)は短いタイムアウトで素早く失敗させる
_DISCOVERY_CONFIG = Config(
    retries={"max_attempts": 2, "mode": "adaptive"},
    connect_timeout=3,
    read_timeout=8,
)
# 推論呼び出しは本番想定のアダプティブリトライ
_RUNTIME_CONFIG = Config(
    retries={"max_attempts": 5, "mode": "adaptive"},
    connect_timeout=5,
    read_timeout=60,
)

# 出力トークン上限は必ず明示的に設定する(未設定はモデル最大値を予約し
# 想定外の ThrottlingException の原因になるため)。
# temperature は新しめの Claude では非対応のため送信しない。
MAX_TOKENS = 1024


# ---------------------------------------------------------------------------
# boto3 クライアント(リージョン単位で 1 つだけ生成して使い回す)
# ---------------------------------------------------------------------------
@st.cache_resource(show_spinner=False)
def bedrock_control_client(region: str):
    """制御プレーン(bedrock)クライアント。モデル一覧の検出に使う。"""
    return boto3.client("bedrock", region_name=region, config=_DISCOVERY_CONFIG)


@st.cache_resource(show_spinner=False)
def bedrock_runtime_client(region: str):
    """データプレーン(bedrock-runtime)クライアント。推論呼び出しに使う。"""
    return boto3.client("bedrock-runtime", region_name=region, config=_RUNTIME_CONFIG)


# ---------------------------------------------------------------------------
# アカウントで実際に利用可能なリージョン / モデルの検出
# ---------------------------------------------------------------------------
@st.cache_data(ttl=3600, show_spinner="利用可能なリージョンを確認中...")
def discover_regions() -> list[str]:
    """候補リージョンを走査し、Anthropic モデルを列挙できたものだけ返す。

    認証情報が無い / 失効している場合は CREDENTIAL_ERRORS を送出する。
    """
    available: list[str] = []
    for region in CANDIDATE_REGIONS:
        try:
            resp = bedrock_control_client(region).list_foundation_models(
                byProvider="anthropic", byOutputModality="TEXT"
            )
        except CREDENTIAL_ERRORS:
            raise
        except (ClientError, BotoCoreError):
            # そのリージョンで Bedrock 非対応 / 権限不足などはスキップ
            continue
        if resp.get("modelSummaries"):
            available.append(region)
    return available


@st.cache_data(ttl=3600, show_spinner="モデルを確認中...")
def discover_models(region: str) -> list[tuple[str, str]]:
    """リージョンで呼び出し可能な Anthropic テキストモデルを返す。

    返り値は (表示名, 呼び出しID) のリスト。クロスリージョン推論プロファイルが
    存在する場合はそのプロファイルIDを優先する(新しめのモデルはプロファイル
    必須のことが多いため)。
    """
    control = bedrock_control_client(region)

    summaries = control.list_foundation_models(
        byProvider="anthropic", byOutputModality="TEXT"
    ).get("modelSummaries", [])

    # ベースモデルID -> 推論プロファイルID の対応表を構築
    profile_for_model: dict[str, str] = {}
    try:
        profiles = control.list_inference_profiles(
            typeEquals="SYSTEM_DEFINED"
        ).get("inferenceProfileSummaries", [])
    except (ClientError, BotoCoreError):
        profiles = []
    for profile in profiles:
        if profile.get("status") != "ACTIVE":
            continue
        for model in profile.get("models", []):
            arn = model.get("modelArn", "")
            base_id = arn.split("foundation-model/")[-1]
            if base_id:
                profile_for_model.setdefault(base_id, profile["inferenceProfileId"])

    models: list[tuple[str, str]] = []
    seen: set[str] = set()
    for fm in summaries:
        if fm.get("modelLifecycle", {}).get("status") != "ACTIVE":
            continue
        if not fm.get("responseStreamingSupported"):
            continue
        base_id = fm["modelId"]
        types = fm.get("inferenceTypesSupported", [])
        if base_id in profile_for_model:
            invoke_id = profile_for_model[base_id]
        elif "ON_DEMAND" in types:
            invoke_id = base_id
        else:
            # オンデマンド非対応かつ対応プロファイルなし -> 呼び出せない
            continue
        if invoke_id in seen:
            continue
        seen.add(invoke_id)
        label = fm.get("modelName") or base_id
        models.append((label, invoke_id))

    models.sort(key=lambda m: m[0])
    return models


# ---------------------------------------------------------------------------
# Converse Stream による応答(st.write_stream に渡すジェネレーター)
# ---------------------------------------------------------------------------
def to_converse_messages(history: list[dict]) -> list[dict]:
    """セッションの会話履歴を Converse API のメッセージ形式へ変換する。"""
    return [
        {"role": m["role"], "content": [{"text": m["content"]}]}
        for m in history
        if m["role"] in ("user", "assistant")
    ]


def stream_bedrock(
    region: str, model_id: str, history: list[dict], system_prompt: str = ""
) -> Iterator[str]:
    """bedrock-runtime の converse_stream を呼び出しテキスト差分を逐次 yield する。"""
    client = bedrock_runtime_client(region)
    params: dict = {
        "modelId": model_id,
        "messages": to_converse_messages(history),
        "inferenceConfig": {"maxTokens": MAX_TOKENS},
    }
    if system_prompt.strip():
        params["system"] = [{"text": system_prompt}]
    response = client.converse_stream(**params)
    for event in response["stream"]:
        if "contentBlockDelta" in event:
            yield event["contentBlockDelta"]["delta"].get("text", "")


# ---------------------------------------------------------------------------
# カスタムコンポーネント (CCv2): 回答メッセージのコピー / 評価ボタン
# ---------------------------------------------------------------------------
# 標準ウィジェットには無いクリップボードコピーと評価操作を双方向コンポーネント
# として実装する。配色は --st-* テーマ変数を参照し、ライト/ダーク・カスタム
# テーマに自動追従する(既定の isolate_styles=True により Shadow DOM 内に隔離)。

_ACTIONS_HTML = """
<div id="actions">
  <button id="copy" type="button" title="回答をコピー">
    <span class="ico">&#x2398;</span><span>コピー</span>
  </button>
  <button id="up" type="button" title="参考になった">&#128077;</button>
  <button id="down" type="button" title="参考にならなかった">&#128078;</button>
</div>
"""

_ACTIONS_CSS = """
#actions { display: flex; gap: 6px; margin-top: 4px; }
#actions button {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: 12px; line-height: 1;
  padding: 5px 9px;
  background: transparent;
  color: var(--st-text-color);
  border: 1px solid var(--st-border-color, rgba(128,128,128,0.35));
  border-radius: var(--st-base-radius, 8px);
  cursor: pointer;
  font-family: var(--st-font, inherit);
}
#actions button:hover { background: var(--st-secondary-background-color); }
#actions button.selected {
  border-color: var(--st-primary-color);
  color: var(--st-primary-color);
  font-weight: 600;
}
#actions .ico { font-size: 13px; }
"""

_ACTIONS_JS = """
export default function (component) {
  const { data, parentElement, setStateValue, setTriggerValue } = component
  const root = parentElement.querySelector("#actions")
  if (!root) return

  const copyBtn = root.querySelector("#copy")
  const upBtn = root.querySelector("#up")
  const downBtn = root.querySelector("#down")

  const fb = (data && data.feedback) || null
  upBtn.classList.toggle("selected", fb === "up")
  downBtn.classList.toggle("selected", fb === "down")

  copyBtn.onclick = async () => {
    try {
      await navigator.clipboard.writeText((data && data.text) || "")
    } catch (e) {
      /* クリップボード権限が無い環境では黙ってスキップ */
    }
    setTriggerValue("copied", true)
  }
  upBtn.onclick = () => setStateValue("feedback", fb === "up" ? null : "up")
  downBtn.onclick = () => setStateValue("feedback", fb === "down" ? null : "down")
}
"""

_MESSAGE_ACTIONS = st.components.v2.component(
    "message_actions",
    html=_ACTIONS_HTML,
    css=_ACTIONS_CSS,
    js=_ACTIONS_JS,
)


def message_actions(
    idx: int,
    text: str,
    feedback: str | None,
    *,
    on_copied: Callable[[], None] | None = None,
    on_feedback: Callable[[], None] | None = None,
):
    """1 件のアシスタント回答に対する操作ボタンを描画する。

    戻り値の result から feedback(state: "up"/"down"/None)と
    copied(trigger: コピー実行時に True)を読み取れる。
    """
    return _MESSAGE_ACTIONS(
        key=f"msg-actions-{idx}",
        data={"text": text, "feedback": feedback},
        on_feedback_change=on_feedback or (lambda: None),
        on_copied_change=on_copied or (lambda: None),
    )


# ---------------------------------------------------------------------------
# ページ設定
# ---------------------------------------------------------------------------
st.set_page_config(page_title="Bedrock チャット", page_icon=":material/smart_toy:")

if "messages" not in st.session_state:
    st.session_state.messages = []

# ---------------------------------------------------------------------------
# サイドバー: モデル / リージョン選択(実利用可能なもののみ)、履歴クリア
# ---------------------------------------------------------------------------
with st.sidebar:
    st.header(":material/settings: 設定")

    credentials_ok = True
    regions: list[str] = []
    try:
        regions = discover_regions()
    except CREDENTIAL_ERRORS:
        credentials_ok = False

    if not credentials_ok:
        st.error(
            "ローカル AWS 認証情報が見つからない、または失効しています。"
            "`aws configure` / SSO ログイン / 環境変数などで認証情報を設定してください。"
        )
        model_name = model_id = region = None
    elif not regions:
        st.error(
            "Bedrock で Anthropic モデルを利用できるリージョンが見つかりませんでした。"
            "アカウントのモデルアクセス設定をご確認ください。"
        )
        model_name = model_id = region = None
    else:
        default_region = boto3.Session().region_name
        region_index = regions.index(default_region) if default_region in regions else 0
        region = st.selectbox(
            "リージョン(場所)",
            options=regions,
            index=region_index,
            format_func=lambda r: f"{REGION_LABELS.get(r, r)} ({r})",
            help="アカウントで Anthropic モデルを検出できたリージョンのみ表示",
        )

        models = discover_models(region)
        if not models:
            st.warning("このリージョンで呼び出し可能な Anthropic モデルがありません。")
            model_name = model_id = None
        else:
            labels = [m[0] for m in models]
            model_name = st.selectbox(
                "推論モデル",
                options=labels,
                help="選択リージョンでストリーミング推論可能な Anthropic モデルのみ表示",
            )
            model_id = dict(models)[model_name]
            st.caption(f"呼び出しID: `{model_id}`")

    st.text_area(
        "システムプロンプト",
        key="system_prompt",
        placeholder="例: あなたは簡潔に回答する日本語アシスタントです。",
        help="モデルに与える役割や制約。空欄の場合は送信しません。",
    )

    st.divider()

    if st.button(
        "会話履歴をクリア",
        icon=":material/delete_sweep:",
        width="stretch",
        disabled=not st.session_state.messages,
    ):
        st.session_state.messages = []
        st.rerun()

# ---------------------------------------------------------------------------
# メイン: チャット画面
# ---------------------------------------------------------------------------
st.title(":material/smart_toy: Bedrock チャット")
st.caption("AWS Bedrock 経由で Anthropic モデルと対話します")

# 既存の会話履歴を表示。アシスタント回答にはコピー/評価ボタンを付与する。
for i, msg in enumerate(st.session_state.messages):
    with st.chat_message(msg["role"]):
        if msg.get("error"):
            st.error(msg["content"])
        else:
            st.write(msg["content"])
        if msg["role"] == "assistant" and not msg.get("error"):
            result = message_actions(i, msg["content"], msg.get("feedback"))
            if result.feedback != msg.get("feedback"):
                msg["feedback"] = result.feedback
            if result.copied:
                st.toast("回答をコピーしました", icon=":material/content_copy:")

# モデル選択ができている場合のみ入力を受け付ける
ready = model_id is not None
if prompt := st.chat_input("メッセージを入力してください", disabled=not ready):
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.write(prompt)

    runtime = bedrock_runtime_client(region)
    with st.chat_message("assistant"):
        try:
            response = st.write_stream(
                stream_bedrock(
                    region,
                    model_id,
                    st.session_state.messages,
                    st.session_state.get("system_prompt", ""),
                )
            )
            st.session_state.messages.append(
                {"role": "assistant", "content": response, "feedback": None}
            )
        except runtime.exceptions.AccessDeniedException:
            st.session_state.messages.append({
                "role": "assistant",
                "content": (
                    "このモデルへのアクセスが有効化されていません。"
                    "Bedrock のモデルアクセス設定でこのモデルを有効化してください。"
                ),
                "error": True,
            })
        except runtime.exceptions.ValidationException as e:
            st.session_state.messages.append({
                "role": "assistant",
                "content": f"リクエストが不正です: {e.response['Error']['Message']}",
                "error": True,
            })
        except ClientError as e:
            err = e.response["Error"]
            st.session_state.messages.append({
                "role": "assistant",
                "content": f"Bedrock 呼び出しエラー: {err['Code']} - {err['Message']}",
                "error": True,
            })
    st.rerun()

# サイドバーにフィードバック集計を表示(履歴処理後の最新値で算出)
up = sum(1 for m in st.session_state.messages if m.get("feedback") == "up")
down = sum(1 for m in st.session_state.messages if m.get("feedback") == "down")
if up or down:
    with st.sidebar:
        st.divider()
        st.caption("フィードバック")
        c1, c2 = st.columns(2)
        c1.metric("良い", up)
        c2.metric("いまいち", down)

最後に

スキルを.claude/skills/に置いて依頼を投げるだけで、環境構築・モック実装・テーマ・カスタムコンポーネントまでを、Streamlitのお作法に沿った形でエージェントが一気に組み上げてくれました。特に「最新版のStreamlitを使うこと」「テーマはCSSではなくconfig.tomlで」といった、知らないとハマりがちなポイントがスキル経由で自動的に守られる点は実用的だと感じました。

定型的なお作法をスキルが肩代わりしてくれるぶん、アプリ要件などの本質的な部分に集中できるのが良い点だと感じました。Streamlitで何か作る際の初速を大きく上げてくれるツールだと思います。

今回は以上です。最後までお読みいただきありがとうございました。


生成AI活用はクラスメソッドにお任せ

過去に支援してきた生成AIの支援実績100+を元にホワイトペーパーを作成しました。御社が抱えている課題のうち、どれが解決できて、どのようなサービスが受けられるのか?4つのフェーズに分けてまとめています。どうぞお気軽にご覧ください。

生成AI資料イメージ

無料でダウンロードする

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事