Workspace Studio と GAS で Meet の議事録を自動集約・Markdown 化してローカルで活用できるようにしてみた
はじめに
データ事業本部のkasamaです。
今回は、Meetの議事録をAI Agentのコンテキストとして活用するために、Google Workspace Studio と Google Apps Script で Markdown に自動変換してローカルに同期する仕組みを作ってみたいと思います。
前提
Google Workspace Studio
Google Workspace Studio は、Google Workspace 上で AI エージェントを作成・管理・共有するためのノーコードプラットフォームです。自然言語で自動化したい内容を記述するだけで、Gmail や Drive、Sheets 等の Workspace アプリ間のワークフローを構築できます。2025年12月に発表され、順次ロールアウトされています。私は Gemini Enterprise プランを利用しています。個人アカウント(無料)では Google Workspace Studio は利用できません。対応エディション等の詳細は公式ページをご確認ください。
目的
Meetで生成された議事録は Google Docs として各ミーティングの共有ドライブやマイドライブに散在します。複数プロジェクトを並行で進めていると、Docsを探すのに Google Drive の検索に頼ることになります。この議事録を YAML frontmatter 付きの Markdown に変換してローカルに同期すれば、Claude Code等の AI Agentがコンテキストとして直接読み込めるようになります。議事録をローカルで活用できるようにすることが今回の目的です。
アーキテクチャ
処理の全体像は以下の2つの構成図で示します。


全体は大きく3つのフェーズに分かれます。
- Google Workspace(自動): まず Meet で Gemini が議事録を自動生成します。会議終了から数分後に
gemini-notes@google.comから通知メールが届き、このメールをトリガーとして Workspace Studio フローが起動します。フロー内で Gemini が議事録を YAML frontmatter 付きの Markdown 構造に変換し、新しい Google Docs としてマイドライブの監視フォルダに保存します。 - GAS + Drive Desktop(自動): GAS の定期トリガーが監視フォルダ内の新規・更新ファイルをチェックします。対象の Google Docs を Drive API v3 の Export API で Markdown に変換し、ファイル名に含まれるキーワードに基づいてプロジェクト別のサブフォルダにルーティングします。変換された Markdown ファイルは Google Drive Desktop によってローカルに自動同期されます。
- ローカル活用: ローカルに同期された .md ファイルを Claude Code 等の AI Agent がコンテキストとして直接読み込めます。
Google Drive のフォルダ構成
マイドライブ/
├── meeting_notes_docs/ ← 監視フォルダ(Docs をフラットに配置)
│ ├── プロジェクトA_定例_2025-02-10.gdoc
│ ├── プロジェクトB_進捗報告.gdoc
│ └── 社内MTG_メモ.gdoc
└── meeting_notes_md/ ← 出力フォルダ(.md を出力)
├── project_a/ ← ファイル名に「プロジェクトA」を含む → 自動振り分け
├── project_b/ ← ファイル名に「プロジェクトB」を含む → 自動振り分け
└── others/ ← どのキーワードにもマッチしない → others
監視フォルダにはサブフォルダを作らず、全ての Docs をフラットに配置します。出力フォルダのサブフォルダは ROUTING_RULES に基づいて GAS が自動作成します。
実装
実装コードはGitHubに格納しています。
64_gas_gdocs_to_markdown/
├── gas-gdocs-to-markdown.js # GAS スクリプト本体
└── README.md # セットアップ手順
Workspace Studio フロー
Workspace Studio で3ステップのフローを構築します。Workspace Studio は以下から開けます。
Step 1 では、メールトリガーを設定します。Meetで議事録が生成されると、会議終了後に gemini-notes@google.com からNotes:という件名で通知メールが届きます。この送信元アドレスと件名をFromとSubject hasに設定することで、議事録の生成を検知してフローを自動起動します。


Step 2 では、Ask Gemini で議事録を構造化します。生成された議事録 Docs の「メモ」タブから内容をコピーし、以下のプロンプトで YAML frontmatter 付きの Markdown 構造に変換します。またVariablesで、Step1のemailで受信したDrive linksを設定します。Email attachments file nameでも同じことはできそうですが、Drive linkで成功したのでこちらを利用しています。EmailにドキュメントへのlinkがついているのでそれをGeminiに読み取ってもらうための変数です。
Copy the document tab "メモ" and convert to Markdown.
Start with this header (one field per line):
---
date: YYYY-MM-DD
project: lowercase-name
summary: 日本語で一文要約
---
Then the meeting content.

Step 3 では、Create a doc で構造化した内容を新しい Google Docs としてmeeting_notes_docsに保存します。ファイル名はemailの件名を、ContentはStep2のGeminiの出力を指定しています。この Docs を後段の GAS が Markdown に変換します。

Google Apps Script
GAS のトリガーが exportChanged 関数を定期実行します。前回実行時刻以降に更新された Google Docs のみを対象とし、ファイル名に含まれるキーワードでルーティング先を判定します。変換成功後、元の Google Docs はゴミ箱に移動されます。
/**
* Load configuration from Script Properties.
*/
function getConfig_() {
const props = PropertiesService.getScriptProperties();
// ROUTING_RULES format: "keywordA:folder_a,keywordB:folder_b"
const routingRaw = props.getProperty('ROUTING_RULES') || '';
const routingRules = routingRaw
? routingRaw.split(',').map((rule) => {
const [keyword, folder] = rule.split(':').map((s) => s.trim());
return { keyword, folder };
})
: [];
return {
watchFolderId: props.getProperty('WATCH_FOLDER_ID'),
outputFolderId: props.getProperty('OUTPUT_FOLDER_ID'),
routingRules,
};
}
/**
* Determine the routing subfolder name from a file name.
* Returns "others" if no rule matches.
*/
function resolveFolder_(fileName, routingRules) {
for (const rule of routingRules) {
if (fileName.includes(rule.keyword)) {
return rule.folder;
}
}
return 'others';
}
// ============================================================
// Main functions
// ============================================================
/**
* Main: convert Docs that have been added or updated since the last run.
*/
function exportChanged() {
const config = getConfig_();
const props = PropertiesService.getScriptProperties();
const lastRun = props.getProperty('last_run_at');
const watchFolder = DriveApp.getFolderById(config.watchFolderId);
const outputRoot = DriveApp.getFolderById(config.outputFolderId);
const query = buildQuery_(lastRun);
const files = watchFolder.searchFiles(query);
let count = 0;
while (files.hasNext()) {
const file = files.next();
try {
count += processFile_(file, outputRoot, config);
} catch (e) {
Logger.log(`Error: ${file.getName()}`);
}
}
props.setProperty('last_run_at', new Date().toISOString());
Logger.log(count > 0 ? `Done. ${count} file(s) exported.` : 'No changes detected.');
}
/**
* Force-export all files (for initial run).
*/
function exportAll() {
const config = getConfig_();
const props = PropertiesService.getScriptProperties();
const watchFolder = DriveApp.getFolderById(config.watchFolderId);
const outputRoot = DriveApp.getFolderById(config.outputFolderId);
const query = 'mimeType = "application/vnd.google-apps.document"';
const files = watchFolder.searchFiles(query);
let count = 0;
while (files.hasNext()) {
const file = files.next();
try {
count += processFile_(file, outputRoot, config);
} catch (e) {
Logger.log(`Error: ${file.getName()}`);
}
}
props.setProperty('last_run_at', new Date().toISOString());
Logger.log(`Done. ${count} file(s) exported.`);
}
/**
* Process a single file: convert to Markdown, route to subfolder, delete original.
* @return {number} 1 on success, 0 on failure
*/
function processFile_(file, outputRoot, config) {
const docName = file.getName();
const subName = resolveFolder_(docName, config.routingRules);
const outputSub = getOrCreateSubFolder_(outputRoot, subName);
const md = exportAsMarkdown_(file.getId());
const fileName = `${sanitizeFileName_(docName)}.md`;
saveToFolder_(outputSub, fileName, md);
Logger.log(`${docName} → ${subName}/${fileName}`);
file.setTrashed(true);
Logger.log(`Deleted: ${docName}`);
return 1;
}
// ============================================================
// Search and export
// ============================================================
/**
* Build a search query (Google Docs filtered by modified date).
*/
function buildQuery_(lastRunIso) {
let q = 'mimeType = "application/vnd.google-apps.document"';
if (lastRunIso) {
q += ` and modifiedDate > "${lastRunIso}"`;
}
return q;
}
/**
* Fetch Markdown text from a Google Doc via Drive API v3 REST export.
*/
function exportAsMarkdown_(docId) {
const url = `https://www.googleapis.com/drive/v3/files/${docId}/export?mimeType=${encodeURIComponent('text/markdown')}`;
const response = UrlFetchApp.fetch(url, {
headers: { Authorization: `Bearer ${ScriptApp.getOAuthToken()}` },
});
return response.getContentText('UTF-8');
}
// ============================================================
// Utilities
// ============================================================
/**
* Get an existing subfolder by name, or create one if it doesn't exist.
*/
function getOrCreateSubFolder_(parentFolder, subName) {
const folders = parentFolder.getFoldersByName(subName);
if (folders.hasNext()) {
return folders.next();
}
return parentFolder.createFolder(subName);
}
/**
* Save a .md file to a folder (overwrite if it already exists).
*/
function saveToFolder_(folder, fileName, content) {
const existing = folder.getFilesByName(fileName);
if (existing.hasNext()) {
const file = existing.next();
file.setContent(content);
} else {
folder.createFile(fileName, content, 'text/markdown');
}
}
/**
* Remove characters that are invalid in file names.
*/
function sanitizeFileName_(name) {
return name.replace(/[\/\\?%*:|"<>\s]/g, '_').trim();
}
gas-gdocs-to-markdown.js では、設定の読み込み、Markdown 変換、ファイルルーティングを一つのスクリプトにまとめています。
getConfig_() はスクリプトプロパティから全設定を読み込みます。ROUTING_RULES はカンマ区切りの キーワード:フォルダ名 形式で、ファイル名に含まれるキーワードに応じて出力先サブフォルダを決定します。どのキーワードにもマッチしないファイルは others フォルダに振り分けられます。
exportAsMarkdown_() は Drive API v3 の REST エンドポイントで Google Docs を text/markdown 形式にエクスポートしています。ScriptApp.getOAuthToken() で OAuth トークンを取得し、UrlFetchApp.fetch() で API を呼び出します。レスポンスは getContentText("UTF-8") で文字列に変換します。
processFile_() が1ファイルの処理単位です。MD 変換 → ルーティング先のサブフォルダに保存 → 元の Docs をゴミ箱に移動、という流れを処理しています。
exportChanged と exportAll の2つのメイン関数があります。exportChanged は前回実行時刻(last_run_at)以降に更新された Docs のみを対象とする差分エクスポートで、定期トリガーから呼ばれます。exportAll は全ファイルを対象とする初回実行用です。
デプロイ
Google Drive でフォルダを作成
マイドライブに監視フォルダ(meeting_notes_docs)と出力フォルダ(meeting_notes_md)を作成します。各フォルダの URL からフォルダ ID をコピーします。
https://drive.google.com/drive/folders/【このフォルダID】
GAS プロジェクトを作成
-
GAS (https://script.google.com)を開く
-
「新しいプロジェクト」をクリック
-
プロジェクト名を入力(例:
Docs to Markdown) -
デフォルトコードの中身を全て削除
-
gas-gdocs-to-markdown.jsの内容を全て貼り付け -
Ctrl+S(Mac: Cmd+S)で保存する
ここで注意すべき点は、保存しないと関数が認識されないということです。保存すると上部ツールバーの「No functions」が関数一覧に変わります。
スクリプトプロパティを設定
GAS エディタ左メニュー 「プロジェクトの設定」 → 「スクリプト プロパティ」に以下を登録します。
| プロパティ名 | 値 | 説明 |
|---|---|---|
WATCH_FOLDER_ID |
<監視フォルダの ID> |
監視対象フォルダの ID |
OUTPUT_FOLDER_ID |
<出力フォルダの ID> |
.md 出力先の親フォルダの ID |
ROUTING_RULES |
プロジェクトA:project_a,プロジェクトB:project_b |
ファイル名キーワード:出力サブフォルダ |
ROUTING_RULES はカンマ区切りで複数ルールを設定できます。
Google Drive Desktop で同期
公式手順に沿って、Google Drive Desktopを設定します。
デプロイ後確認
Workspace Studio の Test run
まずは、Workspace Studio のフローが正しく動作するか確認します。フロー画面下部の「Test run」をクリックし、既存のメールを選択して「Start」をクリックします。

テストが成功すると、監視フォルダ(meeting_notes_docs)に構造化された Google Docs が作成されます。
何回か試しましたが、YAML frontmatter の改行が意図通りに出力されないケースがありました。ですのでGeminiへのプロンプトは必要に応じて調整してください。

GAS 初回実行
GASのツールバーの関数ドロップダウンで exportAll を選択しRunで実行します。


markdownファイルが出力先に生成されています。


定期実行トリガーを設定
GAS エディタ左メニューの「トリガー」(時計アイコン)をクリックし、右下の「トリガーを追加」から設定します。時間設定は実行して欲しい間隔に設定します。
- Choose which function to run:
exportChanged - Choose which deployment should run:
Head - Select event source:
Time-driven - Select type of time based trigger:
Day timer - Select time of day:
8am to 9am - Failure notification settings:
Notify me daily

markdownに出力されたら、ローカルのVS Codeで参照できたり、AI に読み込ませることができます。

最後に
ここまでできればAI Agentに読み込ませて要約、分析、検索などが容易にできそうです。まだ運用始めて数日なので、不具合あればblogをupdateしたいと思います。どなたかの参考になれば幸いです。







