はじめに
会議の議事録って、書くのも、共有するのも、海外メンバー向けに英訳するのも、毎回地味にしんどくないですか。私はずっとこれが面倒で、「録音を放り込んだら全部終わる」状態にしたかった。
2026年3月に公開された Zoom AI Services(Scribe / Summarizer / Translator の各API)を使えばできそうだったので、実際に営業定例の録音1本から要約・アクションアイテム抽出・英訳・Slack通知までを自動で流すパイプラインを作りました。本記事はその記録です。
完成形はこれ。録音を渡すと、Slackにこういう1通が届きます。

対象読者
- 議事録の作成・共有・英訳がだるいと思っている人
- Zoom AI Services の実用例を、コードで具体的に知りたい人
- Python が少し読める人(コードは全部で数十行です)
先に結論だけ言うと、コア部分は本当に短く書けました。ただし途中で Invalid Access token という一文字に半日溶かしたので、その顛末も正直に書きます(同じ轍を踏む人が絶対いるはず)。
つくったもの(全体像)
やっていることはシンプルで、こういう流れです。
使うのは Zoom AI Services の Summarizer と Translator。文字起こしは今回はZoomの純正機能で用意しました(このあたりの判断は後述します)。実装は Python で、HTTPは httpx、JWT生成に PyJWT、環境変数に python-dotenv、パッケージ管理は uv を使っています。
準備:Build PlatformでキーをとってJWTを作る
Zoom AI Services のAPIは、xoxb- のようなトークンではなく、API key と API secret から自分でJWT(HS256)を署名して Authorization: Bearer <JWT> で叩く方式です。
キーは Zoom の Build Platform から取ります。
- Webポータル(
https://zoom.us/profile)にログイン - ADMIN → Plans and Billing → Plan Management
- Universal Credit の Manage → Build App
- 「API keys」セクションの API Key / API Secret をコピー(同じ場所に View JWT Token もあります)
JWTの生成はこれだけ。iss にAPI key、iat、exp(1時間以内が推奨)を入れてHMAC-SHA256で署名します。
import os, time
import httpx, jwt
from dotenv import load_dotenv
load_dotenv()
API_KEY = os.environ["ZOOM_API_KEY"]
API_SECRET = os.environ["ZOOM_API_SECRET"]
BASE_URL = "https://api.zoom.us/v2/aiservices"
def generate_jwt() -> str:
now = int(time.time())
payload = {"iss": API_KEY, "iat": now - 30, "exp": now + 3600}
return jwt.encode(payload, API_SECRET, algorithm="HS256")
API key / secret は .env に置いて、絶対にGitにコミットしないでください(私は .gitignore に .env を入れています)。記事に載せるスクショでもマスク必須です。
文字起こしは「Zoom純正」で用意した(Scribe APIとの違い)
今回の文字起こしは、Scribe APIではなくZoomの会議のネイティブ文字起こし(AI Companion)をエクスポートして使いました。2人で実際に喋った会議なので、発言者(佐藤 / 田中)が最初から分かれているのが良いところです。
Scribe APIとの役割の違いはこう整理できます。
- Scribe API … 音声ファイルを投げて文字起こし。
diarization: trueを付けるとspeaker_1speaker_2… と声で話者を分離してくれる - Zoom純正 … 会議の参加者ごとに最初から分離済み。エクスポートすればテキストがすぐ手に入る
「とりあえず議事録テキストが欲しい」なら純正が速い。一方で精度を比べたくなる場面が後で出てきます(最終章で触れます)。
ちなみに、この純正文字起こしがわりと盛大に誤変換していて、それが逆に面白い検証材料になりました。たとえば「営業定例」が「営業手入れ」になっていたり。これは後半の精度の話で詳しく見ます。私の滑舌の問題もあるかもしれませんが![]()
Summarizerで要約とアクションアイテムを抜く
ここからが本番。Summarizer APIに文字起こしを渡すと要約してくれます。日本語は language に ja-jp、会議なら summary_type は conversation。task を変えると出力が変わります。
def summarize(text: str, task: str = "summary", language: str = "ja-jp") -> dict:
return _post("/summarizer/summarize", {
"input": {"text": text},
"config": {"task": task, "language": language, "summary_type": "conversation"},
})
task は recap / action_items / summary / full_summary から選べます。私は「要約」と「アクションアイテム」が欲しかったので、summary と action_items の2回叩きました。
ここで1つ引っかかったのが、summary の結果が summary_text の中に複数バージョン(概要から詳細まで、実行ごとに3〜4本)まとめて返ってくること。全部Slackに流すとうるさいので、## で分割して一番詳しい1本だけ採用しました。
import re
def pick_one_summary(text: str) -> str:
secs = [s.strip() for s in re.split(r"(?m)^##\s+", text) if s.strip()]
return "## " + max(secs, key=len) if secs else text.strip()
実際の出力(アクションアイテム)はこんな感じ。担当ごとに、期限つきできれいに割れてくれました。
**佐藤**
- A社案件の15%値引きについて、部長に承認を確認する(明日まで)。
- 来月の予算配分を見直し、6月19日までに共有する(新規リード対策も含む)。
**田中**
- A社案件の構成を見直し、再提出する(6月27日(水)まで)。
- B社の競合敗戦理由をヘアリングし、次への活用策をまとめる(今週中)。
- C社のクロージングに集中し、今月の目標達成に努める。
Translatorで英訳して海外メンバーにも渡す
要約ができたら、Translatorで英訳します。日本語から英語は source_language が ja-JP、target_languages が ["en-US"]。
def translate(text: str, source="ja-JP", target="en-US") -> dict:
return _post("/translator/translate", {
"text": text[:4000],
"config": {"source_language": source, "target_languages": [target]},
})
Translatorは1回につき1言語・最大4000文字です。なので「文字起こし全文」ではなく「要約」を訳すのが正解。さらにSummarizerのsummaryは複数バージョンをまとめて返すので、当初は全部訳していました。1本に絞ってから訳すようにして、英訳の入力を280文字まで抑えました(下の実測表を参照)。
Slackに1通でまとめて自動通知
最後に、要約・アクションアイテム・英訳を1通にまとめてSlackへ。Incoming Webhook に {"text": ...} をPOSTするだけです。
import os, httpx
from dotenv import load_dotenv
load_dotenv()
def post_to_slack(summary: str, action_items: str, english: str) -> None:
url = os.environ["SLACK_WEBHOOK_URL"]
text = (
"*:memo: 会議要約*\n" + summary + "\n\n"
"*:white_check_mark: アクションアイテム*\n" + action_items + "\n\n"
"*:globe_with_meridians: English summary*\n" + english
)
httpx.post(url, json={"text": text}, timeout=30).raise_for_status()
実は最初、SlackのユーザートークンをもらってWeb APIで送ろうとしたのですが、そのトークンが xoxe.xoxp- 形式で12時間で失効するローテーション付きでした。記事の再現性も考えると、チャンネルが内包されていてスコープ設定もいらないIncoming Webhookのほうが圧倒的に楽です。
Webhook URL は「知っていれば誰でもそのチャンネルに投稿できる」実質シークレットです。.env 管理+スクショではマスクを。
全部つなぐ:録音から通知までを1コマンドで
あとはオーケストレーション。文字起こしテキストを渡すと、要約 → アクション → 英訳 → Slack を順に実行して output/ に保存します。
uv run python src/run.py meeting.md --slack
[1/4] transcript: 1036 文字
[2/4] 要約 完了 (9.4s)
[3/4] アクションアイテム 完了
[4/4] 英訳 完了
Slackへ投稿しました ✓
ここまでで「録音を渡すと議事録がSlackに流れる」が完成しました。コア部分は本当に短い。…なんですが、ここに辿り着くまでが一番しんどかったので、その話をします。
ハマった話:「Invalid Access token」の犯人は l と I だった
最初に summarize を叩いたとき、返ってきたのはこれでした。
{"code": 124, "message": "Invalid Access token"}
キーは画面からコピー(したつもり)。JWTもローカルでちゃんと生成できている。なのに弾かれる。ここから切り分けが始まりました。
-
.envから読んだキーをrepr()で確認 → 不可視文字や空白なし、長さもOK - マシンの時計 → 正常(
iat/expがおかしいわけではない) - 署名方式を疑い、公式が使う
jsrsasign(Node)とPyJWTで同じ秘密鍵・同じメッセージのHMACを計算して突合 → 1バイトも違わず一致。つまり自分のコードは無罪
ここまでで「キーも時計も署名も正しい」。じゃあ何が、と詰まったので、最後の手として Build Platform の「View JWT Token」(Zoom自身が発行する出来合いトークン)をデコードして、iss(=API key)を自分の .env と1文字ずつ比べました。
(キーは例示用のダミー値。実際は20文字超のランダム文字列です)
Zoom発行 : Kb3Qz7lTfeRR4ph_91vC ← 7文字目は 小文字のエル (l)
自分の.env: Kb3Qz7ITfeRR4ph_91vC ← 7文字目は 大文字のアイ (I)
犯人は小文字のエル(l)を大文字のアイ(I)と読み違えていたこと。スクショから目で写したときにやられました。1文字直したら、何事もなかったように通りました。
ランダム文字列のAPIキーは、目で写さず必ず「Copy」ボタンで。l と I と 1、O と 0 は本当に見分けがつきません。怪しいときは、サービス側が発行する正規トークンをデコードして突き合わせるのが一番速い切り分けでした。
正直、署名まで疑い始めたあたりが一番つらかった。でも「自分のコードは正しい」と数字で確定させてから外側を疑えたので、遠回りに見えて結局これが近道でした。
どこまで正確? 純正ASRの誤変換を要約AIは直せるのか
ここが個人的に一番おもしろかったところ。検証用に台本へ正解(ground truth)を仕込んでおき、純正文字起こし → 要約の流れで、どこまで正しく残るかを見ました。
まず、純正の文字起こしがなかなかカオスでした。
| 本来 | 純正ASRの結果 |
|---|---|
| 営業定例 | 営業手入れ |
| 確定が980万 | 確定ガス9,800,000 |
| フォーキャスト | 方。キャスト |
| ナーチャリング | ネアンチャリング |
| 失注したB社 | 出張したB社 |
| C社 | 支持者 / ししゃゆ |
| 6月17日 | 6月27日 |
ところが、Summarizerに通すと文脈で勝手に直してくれたものが結構ありました。
- 「確定ガス9,800,000」→ 要約では「確定が9,800,000」
- 「支持者」「ししゃゆ」→ ちゃんと「C社」
- 「終戦を出します」→ アクションでは「再提出する」
LLMベースの要約は、多少の音声認識ミスならノイズとして吸収してくれる。これは素直にすごい。
一方で、直せなかったものもはっきりありました。
- 日付「6月17日」→ ASRが「6月27日」と誤認 → 要約・アクションでもそのまま「6月27日」
- 「ヒアリング」→「ヘアリング」も残存
正解と突き合わせると、アクションアイテムは正解4件中3件が完全一致、外したのは唯一この日付(ASR起因)でした。
ここから言えるのは、要約AIは意味の通る誤りは救えるが、固有の数字や日付みたいな「文脈から推測できないハードな情報」は救えないということ。つまり最終品質は文字起こしの精度で決まる。だからこそ、次はScribe APIと精度を比べたくなりました(後述)。
実測:1会議でどれだけ処理したか(usageで見える)
Zoom AI Services はレスポンスの usage に処理文字数(input_units / output_units)が入るので、何文字処理したかをそのまま実測できます。この営業定例(文字起こし1036文字)1本での内訳がこれです。
| 呼び出し | 入力(文字) | 出力(文字) |
|---|---|---|
| 要約(summary) | 1,036 | 1,008 |
| アクション(action_items) | 1,036 | 189 |
| 英訳(translate) | 280 | 781 |
| 合計 | 2,352 | 1,978 |
処理量がAPIから数値で返るので、「どの呼び出しが重いか」がそのまま見えます。前述のとおり、要約は複数バージョンが返るのを1本に絞ってから英訳したので、英訳の入力(280文字)は小さく抑えられています。料金体系は変動するため本記事では触れませんが、この usage を見れば処理量ベースで見積もりや最適化の判断ができます。
まとめと、次にやりたいこと
録音1本から、要約・アクションアイテム・英訳・Slack通知までを数十行で自動化できました。やってみて効いたのは2つ。usage で処理量を文字単位で実測できること、そしてLLM要約が音声認識のノイズをかなり吸収してくれること。逆に、日付のようなハードな情報は文字起こし精度がそのまま効くので、入力の質がボトルネックだと分かりました。
なので次は、文字起こしを Zoom純正 vs Scribe API(diarization: true)で精度の両面で比べてみたい。同じ会議音声で取ったら、また追記します。
あなたのチームの議事録、まだ手で書いていませんか。もし試したら、どのくらい楽になったか教えてもらえると嬉しいです。コード一式はリポジトリに置いておきます。
参考リンク

