はじめに
「Amazon Echoに話しかけるだけで、買い物メモをDiscordチャンネルに自動投稿したい」という個人開発を試みたところ、想定より遥かに大きな壁にぶつかりました。
作りたかったのはシンプルな構成です。
Amazon Echo(音声入力)
→ カスタムAlexaスキル
→ AWS Lambda
→ Discord Webhook → Discordチャンネル
しかし実装を進める中で、
- Alexa Developer ConsoleのJSON Editorでの謎のバリデーションエラー
- カスタムスキルに向けた発話のはずが、Amazon純正の「買い物リスト」機能に横取りされる問題
- 「これで解決できる」と思って実装したAPIが、実は2年前に廃止済みだった話
- 2026年現在も進行中の、Amazon側の未解決バグ
など、次々と壁にぶつかりました。同じ問題で消耗する人(あるいはAIエージェント)が一人でも減ることを願って、つまずいた点と最終的な回避策を記録します。
環境
- Alexa Developer Console(カスタムスキル、ja-JPロケール)
- AWS Lambda(Python 3.12、標準ライブラリのみ)
- Discord Webhook
つまずきポイント1: サンプル発話のバリデーションエラー
半角英字がそのまま使えない
ja-JPロケールのサンプル発話に、ブランド名などの半角英字(例: Discord)をそのまま書くと、以下のエラーで弾かれます。
Sample utterances can consist of only unicode characters, spaces, periods for abbreviations,
underscores, possessive apostrophes, and hyphens.
エラーメッセージだけを読むと「Unicode文字なら通るはず」と誤読しがちですが、実態はja-JPロケールでは半角英字を直接書けないという制約でした。カタカナ表記(Discord → ディスコード)に置き換えることで解決します。
スロット単体のみのサンプル発話は不可
"samples": ["{ItemName}"]
のように、キャリアフレーズ(前後の言葉)が一切ない、スロットだけのサンプル発話も以下のエラーで拒否されます。
Sample utterance "{ItemName}" in intent "..." must include a carrier phrase.
Sample intent utterances with phrase types cannot consist of only slots.
"{ItemName} を追加して" のように、必ず何らかの言葉と組み合わせる必要があります。
スロットの前後にスペースが必要なケースがある
"ディスコードに{ItemName}を追加して" のように、スロットの前後どちらにもテキストが直接隣接する(スペースなしで挟まれる)形だと、これも同様のバリデーションエラーになりました。日本語は本来スペース区切りを使わない言語ですが、スロット記法 {...} の解析はスペース区切りに依存しているようで、
"ディスコードに {ItemName} を追加して"
のようにスロットの前後にスペースを入れることで解消しました。ただし、スロットが文頭・文末にあり片側にしかテキストが隣接しない場合("{ItemName} を追加して" など)はスペースなしでも問題ありませんでした。
つまずきポイント2(本題): Amazon純正「買い物リスト」に発話を奪われる
ここが最大の壁でした。
現象
カスタムスキルの呼び出し名を"言わずに"、あるいは呼び出し名だけを言って本文を一般的な表現(「〇〇を追加して」など)にすると、自分のスキルではなくAmazon純正の「買い物リスト」機能が反応し、「〇〇を買い物リストに追加しました」と返されてしまいました。しかもテキスト入力を受け付けるAlexa Developer Consoleの「テスト」タブでは正しく自分のインテントに来るのに、実機Echoの音声入力でのみ純正機能に奪われる、というケースもありました。
原因(推測含む)
- Alexaは、スキル名を明示しない一発発話に対して、有効な全スキル+純正機能(買い物リスト・メモ・タイマー等)を横断してどのハンドラに割り当てるか統計的に判定します。純正機能は学習データ量が桁違いに多いため、"〇〇を追加して" のような一般的な言い回しでは、個人のカスタムスキルはまず勝てません。
- コンソールの「テキストテスト」は、この端末側/プラットフォーム側のグローバルな判定を経由せず、常に指定したスキルへ直接ディスパッチされます。そのためテキストテストが通っても実機音声では再現しないことがあるという、非常に厄介な非対称性があります。
対策1: 呼び出し名を明示して発話する(基本)
「アレクサ、{呼び出し名}を開いて」→(応答を待つ)→「牛乳を追加して」
のように、スキル名を明示して呼び出せば、以降の発話はそのスキルのセッションに閉じて処理されるため、純正機能に奪われにくくなります。逆に、
「アレクサ、{呼び出し名}で牛乳を追加して」
のようなワンショット発話は、呼び出し名が短い単語一つだけだと、Alexaの音声解析が「呼び出し名の切れ目」を認識できず、発話全体({呼び出し名}で牛乳)がまるごと一つの商品名として純正の買い物リストに吸収されてしまう現象を実際に確認しました。呼び出し名を複数音節にする、あるいはワンショットを避けて2段階発話にすることで回避できます。
対策2: 呼び出し名変更直後は実機の同期を待つ
Developer Console上で「ビルド成功」と表示されても、実機Echo側への呼び出し名の反映にはタイムラグがあることも分かりました。呼び出し名を変更した直後に実機で「〇〇を開いて」を試して「すみません、お役に立てません」と返ってくる場合は、
- Alexaアプリでスキルを一度「無効化」→「再度有効化」
- Echoデバイスを再起動
- 数分待って再試行
で改善することがあります(テキストテストでは常に最新のビルドに直接アクセスするため、この問題は再現しません)。
遠回りした話: 「Alexa Lists API」で自動連携を試みたが…
発話の奪い合いに疲弊し、「純正の買い物リストへの追加をそのまま活かして、追加イベントを検知してDiscordに転送すればいいのでは」と考え、以下の構成を実装しました。
- スキルに
Alexa Shopping and To-do Listsの読み取り権限を付与 - スキルマニフェストで
AlexaHouseholdListEvent.ItemsCreatedイベントを購読 - Lambdaがイベントを受け取り、Lists APIで商品名を取得してDiscordに転送
実装自体は数十行のPythonで完結し、一見スマートな解決策に見えました。しかし実装完了後にAmazonが2024年7月1日付でList Skill API・List Management REST APIを完全に廃止済みであることが判明しました。つまり、この構成は原理的に絶対動きません。イベントは二度と届かないのに、設定不備だと思い込んで延々とデバッグする羽目になっていたかもしれません。
教訓: 外部プラットフォームの「非推奨機能一覧(Deprecated Features)」ページは、実装前に必ず確認する。 Alexa Skills Kitの場合は Deprecated Features に一覧があります。
副産物: Node.js無しでSMAPIを直接叩く方法
上記の対応中、「スキルイベントの購読はDeveloper ConsoleのGUIには存在せず、スキルマニフェスト(skill.json)をSMAPI(Skill Management API)経由で編集する必要がある」ことも分かりました。公式CLIツールask-cliはNode.js(npm)製ですが、SMAPIの実体はただのREST APIなので、curlだけでNode.js無しに同じ操作ができます。(今回のList Events自体はもう使えませんが、GUIに露出していない他のマニフェスト項目を触る際に応用できる知見として残します)
# 1. LWAセキュリティプロファイルを作成し、Client ID/Secretを取得
# (developer.amazon.com/settings/console/securityprofile/overview.html)
# Web設定の「許可されている返信URL」に http://127.0.0.1:9090/cb を追加
# 2. ブラウザで以下を開いてログイン・許可 → リダイレクト先URLの code= を控える
# https://www.amazon.com/ap/oa?client_id=<CLIENT_ID>&scope=alexa::ask:skills:readwrite&response_type=code&redirect_uri=http://127.0.0.1:9090/cb
# 3. 認可コードをアクセストークンに交換
curl -X POST https://api.amazon.com/auth/o2/token \
-d "grant_type=authorization_code" \
-d "code=<AUTH_CODE>" \
-d "client_id=<CLIENT_ID>" \
-d "client_secret=<CLIENT_SECRET>" \
-d "redirect_uri=http://127.0.0.1:9090/cb"
# 4. 現在のマニフェストを取得
curl -X GET "https://api.amazonalexa.com/v1/skills/<SKILL_ID>/stages/development/manifest" \
-H "Authorization: Bearer <ACCESS_TOKEN>" -o manifest.json
# 5. manifest.json を編集
# 6. アップロード
curl -X PUT "https://api.amazonalexa.com/v1/skills/<SKILL_ID>/stages/development/manifest" \
-H "Authorization: Bearer <ACCESS_TOKEN>" \
-H "Content-Type: application/json" \
-d @manifest.json
ローカルにNode.jsをインストールしたくない・したくても管理者権限や環境の都合で避けたい場合の選択肢として有効です。
Amazon側の未解決バグ(2026年時点)
Lists APIを諦め、素直に「呼び出し名を明示したカスタムスキル」方式に戻ろうとした際、さらに気になる情報を見つけました。有名なリスト管理アプリ「AnyList」の公認Alexaスキルにおいても、2026年3月20日以降、明示的に「アレクサ、AnyListに〇〇を追加するように伝えて」と呼び出しても、発話がAmazon純正の買い物リストに奪われるという報告が上がっており、AnyList側がAmazonに問題を報告済みにもかかわらず、Amazonから修正時期の情報提供は一切ない状態です。
つまり、呼び出し方をどれだけ工夫しても、100%確実に回避することはできないというのが2026年8月時点の実情です。AnyList側が案内している軽減策は以下の通りでした。
- 一発発話より、「開いて」から始める2段階発話の方が奪われにくい
- 「アレクサ、Alexa+を無効にして」と話しかけ、Amazonの新しいAIアシスタント機能「Alexa+」を無効化すると改善する場合がある(日本ではAlexa+自体が本稿執筆時点で未提供)
- 返ってきた音声が期待通りの文言で始まっているか、毎回確認する
これは自分のスキル実装のバグではなく、プラットフォーム側の既知の不安定要素として運用でカバーするしかない類の問題だと割り切ることにしました。
最終的にたどり着いた構成
- 呼び出し名: 「ディスコード」(単語一つのみ)
- 発話は必ず2段階(「アレクサ、ディスコードを開いて」→「牛乳を追加して」)。ワンショット発話は使わない
- サンプル発話はシンプルに保つ(「{ItemName} を追加して」など)。セッションが開いていればそのスキルのインテントとしてのみ解釈されるため、キャリアフレーズにブランド名を含める必要はなかった
interaction_model.json(抜粋)
{
"interactionModel": {
"languageModel": {
"invocationName": "ディスコード",
"intents": [
{ "name": "AMAZON.CancelIntent", "samples": [] },
{ "name": "AMAZON.HelpIntent", "samples": [] },
{ "name": "AMAZON.StopIntent", "samples": [] },
{ "name": "AMAZON.FallbackIntent", "samples": [] },
{ "name": "AMAZON.NavigateHomeIntent", "samples": [] },
{
"name": "AddShoppingItemIntent",
"slots": [
{ "name": "ItemName", "type": "AMAZON.SearchQuery" }
],
"samples": [
"{ItemName} を追加して",
"{ItemName} を追加",
"{ItemName} を送って"
]
}
]
}
}
}
lambda_function.py(抜粋)
import json
import os
import urllib.request
import urllib.error
import socket
from datetime import datetime, timezone, timedelta
JST = timezone(timedelta(hours=9))
DISCORD_WEBHOOK_URL = os.environ.get("DISCORD_WEBHOOK_URL", "")
REQUEST_TIMEOUT_SEC = 5
class DiscordPostError(Exception):
pass
def post_to_discord(item_name: str) -> None:
if not DISCORD_WEBHOOK_URL:
raise DiscordPostError("DISCORD_WEBHOOK_URL が設定されていません")
now_jst = datetime.now(JST)
timestamp_str = now_jst.strftime("%Y/%m/%d %H:%M")
content = f":shopping_trolley: **{item_name}** を追加しました\n-# {timestamp_str} (JST)"
payload = json.dumps({"content": content}).encode("utf-8")
req = urllib.request.Request(
DISCORD_WEBHOOK_URL,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SEC) as res:
if res.status not in (200, 204):
raise DiscordPostError(f"unexpected status: {res.status}")
except urllib.error.HTTPError as e:
raise DiscordPostError(f"HTTPError: {e.code} {e.reason}") from e
except (urllib.error.URLError, socket.timeout) as e:
raise DiscordPostError(f"URLError/Timeout: {e}") from e
def build_response(speech_text, end_session=True, reprompt_text=None):
response = {
"version": "1.0",
"response": {
"outputSpeech": {"type": "PlainText", "text": speech_text},
"shouldEndSession": end_session,
},
}
if reprompt_text:
response["response"]["reprompt"] = {"outputSpeech": {"type": "PlainText", "text": reprompt_text}}
return response
def handle_add_item(intent):
item_name = intent.get("slots", {}).get("ItemName", {}).get("value")
if not item_name:
return build_response("商品名が聞き取れませんでした。もう一度言ってください。", end_session=False)
try:
post_to_discord(item_name)
except DiscordPostError as e:
print(f"[ERROR] Discord post failed: {e}")
return build_response("メモの追加に失敗しました。時間をおいてもう一度お試しください。")
return build_response(f"{item_name}をディスコードに追加しました。")
def lambda_handler(event, context):
request = event.get("request", {})
request_type = request.get("type")
if request_type == "LaunchRequest":
return build_response(
"ディスコードへようこそ。追加したい商品名を言ってください。",
end_session=False,
reprompt_text="例えば、牛乳を追加して、のように言ってください。",
)
if request_type == "IntentRequest":
intent = request.get("intent", {})
if intent.get("name") == "AddShoppingItemIntent":
return handle_add_item(intent)
# AMAZON.HelpIntent / StopIntent / CancelIntent / FallbackIntent などのハンドリングは省略
return build_response("エラーが発生しました。")
まとめ
| つまずき | 原因 | 対策 |
|---|---|---|
| JSON Editorの文字エラー | ja-JPロケールで半角英字が使えない | カタカナ表記にする |
| 「キャリアフレーズが必要」エラー | スロット単体のサンプル発話は不可 | 前後に言葉を添える |
| 文字種エラー(再発) | スロットの前後がテキストで直接隣接 | スロットの前後にスペースを入れる |
| 純正の買い物リストに奪われる | 一発発話・呼び出し名なし発話は純正機能が学習量で勝つ | 呼び出し名を明示、2段階発話を使う |
| 短い呼び出し名でも一発発話だと奪われる | 呼び出し名の切れ目をAlexaが認識しづらい | ワンショットを避け2段階発話にする、または呼び出し名を長くする |
| 呼び出し名変更後、実機だけ反応しない | コンソールのビルド成功と実機反映の間にタイムラグ | スキルの無効化→再有効化、デバイス再起動、数分待つ |
| Lists API連携が動かない | 2024年7月1日付でAmazonが該当APIを廃止済み | 実装前にAlexa公式の「Deprecated Features」を確認する |
| (2026年時点)明示的呼び出しでも稀に奪われる | Amazon側の未解決の既知バグ(AnyList等でも報告あり) | 2段階発話を徹底し、運用でカバーする |
一言でまとめると、**「Amazon純正のリスト・メモ機能と少しでも意味が重なる発話は、カスタムスキル側でどう頑張っても100%は防げない」**というのが最大の学びでした。完璧な回避策を追い求めるより、2段階発話を徹底し、応答文言で成否を都度確認する運用に倒す方が現実的です。
同じ壁にぶつかっている方(や、これを読んでいるAIエージェント)の参考になれば幸いです。
*本記事は、Claude code(Sonnet5)にて執筆しています。