GAS(Google Apps Script)とOpenAI APIを使い、質問と回答をスプレッドシートに保存する個人用のチャット画面を作ります。
ChatGPTのWeb版そのものを組み込む仕組みではなく、OpenAI APIのテキスト応答をGASのWebアプリに表示する仕組みです。
これにより、ブラウザ上で動作する簡易的なChatGPTチャットボットを作成できます。

本記事のコードは自分だけがアクセスできる設定で試します。利用者別の認証・履歴分離・利用量制限は備えていません。他の人に公開する場合は、それらを追加してから公開範囲を広げてください。
GASで動くChatGPTの作り方
UIと処理ロジックはGASで構築し、ログ保存にはGoogleスプレッドシートを使用します。
また、ChatGPTを利用するためにOpenAIのAPIキーを発行します。
これらの連携により、簡易的なチャットボット環境を構築できます。
ChatGPTのAPIキーの発行
まずはOpenAIの管理画面からChatGPTのAPIキーを発行します。

発行されたシークレットキーは再表示できない場合があるため、必ず控えておきます。
スクリプトプロパティに登録
控えたAPIのシークレットキーを、GASの「スクリプトプロパティ」に登録します。

今回はプロパティ名を「ACCESS_TOKEN」として設定しました。
「値」の入力欄に先ほどのシークレットキーを設定して保存します。

ログ用のスプレッドシートを作成
ログ専用のGoogleスプレッドシートを新規作成し、1行目のA〜D列に「日時」「会話ID」「質問」「回答」の見出しを入力します。コードは2行目以降を履歴として読みます。シートの閲覧・編集権限は自分だけに限定してください。
作成後、スプレッドシートのID(URL内の該当部分)とシート名を控えておきます。
※控えたIDとシート名は、後述するmain.gs内の設定箇所に記述します。

Webアプリの作成
GASエディタ上に必要な各ファイルを構成し、コードを記述していきます。

| ファイル | 備考 |
|---|---|
| app.gs | HTMLファイルを読み込み、Webアプリケーションとして表示可能な形式に変換します。 |
| index.html | 画面のUI(HTML構造)を記述します。 |
| style.html | 画面デザインを定義するCSSを記述します。 |
| script.html | 画面の挙動や非同期通信を制御するフロントエンドJavaScriptを記述します。 |
| main.gs | ChatGPT APIの呼び出しやスプレッドシート連携を行うサーバー側処理を記述します。 |
app.gs
Webアプリとしてアクセスされた際に、HTMLを出力するための初期化処理を記述するファイルです。
function doGet() {
return HtmlService.createTemplateFromFile("index").evaluate();
}index.html
チャット画面の基本構造を定義するファイルです。
画面左側に履歴などを表示するサイドバーを配置し、中央に対話メッセージ領域を設けています。
また、外部ファイル化したCSSとJavaScriptを読み込む記述を含めています。
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<?!= HtmlService.createHtmlOutputFromFile("style").getContent(); ?>
</head>
<body>
<div class="sidebar">
<h2 onclick="startNewConversation()">新規会話</h2>
<div id="historyList"></div>
</div>
<div class="main">
<div class="header">
<label for="modelSelect">モデル選択:</label>
<select id="modelSelect">
<option value="gpt-4.1-mini" selected>gpt-4.1-mini</option>
</select>
</div>
<div class="chat-box" id="chatLog"></div>
<div class="input-area">
<textarea id="inputText" rows="5" placeholder="メッセージを入力..."></textarea>
<button onclick="sendMessage()">送信</button>
</div>
</div>
<?!= HtmlService.createHtmlOutputFromFile("script").getContent(); ?>
</body>
</html>
掲載コードは、テキスト応答用のgpt-4.1-miniに限定した例です。
2026年10月28日時点で、OpenAIはこのモデルのAPI提供を2027年1月14日に終了すると告知しています。
開始前に公式の提供終了予定と、自分のAPIプロジェクトでの利用可否を確認してください。
モデルを変更する場合は、index.htmlの選択肢とmain.gsの許可モデル名を両方変更します。
変更先がChat Completionsと掲載コードのパラメーターに対応するか確認し、短い質問で応答とログ保存を試してください。
後継モデルでの実API通信は未確認で、モデル名だけの置換による動作は保証していません。
音声合成・Realtime用モデルは、このテキスト処理には追加しません。
対応する処理とモデル名はOpenAIのモデル資料、Webアプリの実行者・公開範囲はGASのWebアプリ資料で確認できます。
style.html
チャットUI全体のレイアウトや配色を調整するCSSファイルです。
<style>
body {
font-family: sans-serif;
background-color: #f5f5f5;
margin: 0;
padding: 0;
display: flex;
}
.sidebar {
width: 250px;
background-color: #e6f0fa;
border-right: 1px solid #ccc;
padding: 1em;
height: 100vh;
overflow-y: auto;
}
.sidebar h2 {
font-size: 1em;
margin-top: 0;
cursor: pointer;
color: #1a73e8;
}
.sidebar .history-item {
margin: 0.5em 0;
color: #1a73e8;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.main {
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 1em;
}
.header {
font-size: 1.2em;
font-weight: bold;
margin-bottom: 1em;
}
.chat-box {
width: 100%;
max-width: 800px;
background-color: #fff;
padding: 1em;
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
min-height: 300px;
margin-bottom: 1em;
overflow-y: auto;
}
.input-area {
display: flex;
width: 100%;
max-width: 800px;
}
textarea#inputText {
flex: 1;
padding: 0.5em;
font-size: 1em;
resize: vertical;
}
button {
padding: 0.5em 1em;
margin-left: 0.5em;
font-size: 1em;
}
.message {
margin: 0.5em 0;
padding: 0.5em;
}
.user {
color: #1a73e8;
}
.bot {
color: #0b8043;
white-space: pre-wrap;
}
</style>
続いて、通信処理を担うmain.gsとフロントエンド処理のscript.htmlを実装します。
すべてのコードを実装したあと、Webアプリとしてデプロイすることでブラウザからアクセス可能になります。

ここから先の詳細な実装コードは有料コンテンツとなります。
Googleスプレッドシートの他の自動化操作については、まとめ記事をご確認ください。

script.html
フロントエンド側のJavaScriptを記述するファイルです。
送信ボタンの押下時にGAS側を呼び出す処理や、サイドバーへの対話履歴の描画を担当します。表示する履歴は最新200行です。入力と応答はHTMLとして実行せず文字列として表示します。
<script>
let sending = false;
let conversationId = Date.now().toString();
const chatLog = document.getElementById("chatLog");
const input = document.getElementById("inputText");
const historyList = document.getElementById("historyList");
function startNewConversation() {
if (sending) return;
conversationId = Date.now().toString();
chatLog.innerHTML = "";
input.value = "";
loadSidebar();
}
function appendMessage(role, content) {
const div = document.createElement("div");
div.className = `message ${role}`;
div.textContent = `${role === 'user' ? 'あなた' : 'AI'}: ${content}`;
chatLog.appendChild(div);
}
function groupByThread(data) {
const grouped = {};
data.forEach(item => {
if (!grouped[item.thread]) grouped[item.thread] = [];
grouped[item.thread].push(item);
});
return grouped;
}
function loadSidebar() {
google.script.run
.withSuccessHandler(function(data) {
historyList.innerHTML = "";
if (!Array.isArray(data) || data.length === 0) {
historyList.textContent = "履歴がありません";
return;
}
const grouped = groupByThread(data);
Object.entries(grouped).reverse().forEach(([threadId, messages]) => {
const div = document.createElement("div");
div.className = "history-item";
div.textContent = messages[0].user;
div.onclick = () => loadHistoryFromThread(messages);
historyList.appendChild(div);
});
})
.withFailureHandler(err => {
console.error("履歴取得に失敗:", err);
alert("履歴の読み込みに失敗しました。");
})
.getHistoryFromSheetWithThreads();
}
function loadHistoryFromThread(messages) {
if (sending) return;
conversationId = messages[0].thread;
chatLog.innerHTML = "";
messages.forEach(msg => {
appendMessage('user', msg.user);
appendMessage('bot', msg.bot);
});
}
function sendMessage() {
const userMsg = input.value.trim();
if (!userMsg || sending) return;
if (userMsg.length > 4000) { alert("質問は4000文字以内にしてください。"); return; }
sending = true;
appendMessage('user', userMsg);
input.value = "";
const modelSelect = document.getElementById("modelSelect");
const selectedModel = modelSelect.value;
google.script.run
.withSuccessHandler(function(response) {
sending = false;
appendMessage('bot', response);
loadSidebar();
})
.withFailureHandler(() => {
sending = false;
appendMessage('bot', '送信に失敗しました。GASの設定・API利用状況・シートを確認してください。');
})
.askChatGPTWithModel(userMsg, conversationId, selectedModel);
}
window.onload = loadSidebar;
</script>
main.gs
ChatGPTのAPIへのリクエスト送信やスプレッドシートへの記録処理を記述するファイルです。
冒頭の設定変数に、事前準備で控えたスプレッドシートのIDとシート名を記述します。
ChatGPTのAPIを呼び出すための処理を記述します。
const SHEET_ID = "xxxxxxxxxxxxxxxxxxxxxxxxxxx";
const SHEET_NAME = "シート1";
// デプロイのアクセス権は本人のみに限定する。
function askChatGPTWithModel(userInput, threadId, modelName) {
if (typeof userInput !== 'string' || !userInput.trim() || userInput.length > 4000) {
throw new Error('質問は1〜4000文字にしてください。');
}
if (typeof threadId !== 'string' || !/^\d{10,20}$/.test(threadId)) {
throw new Error('会話IDが不正です。');
}
if (modelName !== 'gpt-4.1-mini') throw new Error('許可されていないモデルです。');
const apiKey = PropertiesService.getScriptProperties().getProperty('ACCESS_TOKEN');
if (!apiKey) throw new Error('APIキーが未設定です。');
const sheet = getLogSheet_();
const payload = {
model: modelName,
messages: [
{role: 'system', content: '質問に日本語で簡潔に回答してください。'},
{role: 'user', content: userInput}
],
max_completion_tokens: 1024
};
const response = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', {
method: 'post', contentType: 'application/json',
headers: {Authorization: 'Bearer ' + apiKey},
payload: JSON.stringify(payload), muteHttpExceptions: true
});
const status = response.getResponseCode();
if (status < 200 || status >= 300) throw new Error('API通信に失敗しました(HTTP ' + status + ')。');
const result = JSON.parse(response.getContentText());
const message = result.choices && result.choices[0] && result.choices[0].message;
if (!message || typeof message.content !== 'string' || !message.content.trim()) {
throw new Error('テキスト応答を取得できませんでした。');
}
const reply = message.content.trim();
// =で始まる入力をシートの数式として評価させない。
const asText = value => /^[=+@-]/.test(value) ? "'" + value : value;
sheet.appendRow([new Date(), threadId, asText(userInput), asText(reply)]);
return reply;
}
// 末尾_の関数はgoogle.script.runから直接呼び出せない。
function getLogSheet_() {
const sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
if (!sheet) throw new Error('ログ用シートが見つかりません。');
const headers = sheet.getRange(1, 1, 1, 4).getDisplayValues()[0];
if (headers.join('|') !== '日時|会話ID|質問|回答') {
throw new Error('A1:D1の見出しを確認してください。');
}
return sheet;
}
function getHistoryFromSheetWithThreads() {
const sheet = getLogSheet_();
const lastRow = sheet.getLastRow();
if (lastRow < 2) return [];
// 表示用文字列として返し、Dateをブラウザに直接渡さない。
const firstRow = Math.max(2, lastRow - 199);
return sheet.getRange(firstRow, 1, lastRow - firstRow + 1, 4)
.getDisplayValues().map(r => ({
timestamp: r[0], thread: r[1], user: r[2], bot: r[3]
}));
}デプロイして表示
GASエディタの「デプロイ」→「新しいデプロイ」で種類をWebアプリにし、実行するユーザーを自分、アクセスできるユーザーを自分のみとする設定でデプロイします。本人限定にできない環境では、この共有ログの例を他人に公開しないでください。コード変更後はデプロイのバージョンも更新します。

デプロイ完了後に発行されるウェブアプリのURLをクリックして開きます。

短い質問で応答とシート2行目への保存を確認し、再読み込み後の履歴表示、通信エラー、送信中の再クリックも試します。ログ保存に失敗してもAPI呼び出しの料金は発生し得るため、エラー時に自動再送はしません。掲載コードはローカルの模擬環境で確認した例であり、GAS上でのデプロイ・実API通信・画面表示は利用環境で確認してください。

以上で、GASを使ったChatGPT風チャットボットの構築手順は完了です。
関連コードは、GitHubのMOTOKI-LLC/cg-method-codeにもあります。リポジトリと本記事の更新時期は異なるため、この手順では記事内の5ファイルを一式で使用してください。
外部のサービスと連携する記事は、ほかにもあります。




GASチャットボットについてよくある質問
このチャットボットは、何でできていますか?
UIと処理ロジックはGAS(Google Apps Script)で作り、ログの保存にはGoogleスプレッドシートを使います。ブラウザ上で動く簡易的なChatGPT風チャットボットで、対話ログはスプレッドシートに自動で記録されます。
使う人にも、ChatGPTのアカウントが必要ですか?
この例は作成者本人だけがアクセスする設定です。OpenAI APIのキーと利用設定が必要ですが、ChatGPTの有料契約は必要ありません。他人に使ってもらうには、認証・履歴分離・利用量制限を追加する必要があります。
費用は、かかりますか?
ChatGPTのAPIは従量課金制です。利用頻度やトークンの消費量に応じて費用が増えるため、使いすぎに注意してください。料金やモデルの種類は、OpenAIの公式ページで最新を確認してください。
APIキーは、どこに保存しますか?
GASのスクリプトプロパティに登録します。この記事では、プロパティ名をACCESS_TOKENとして設定しました。APIキーをコード内に直接書くと流出の危険があるため、コードには書かないでください。
発行したAPIキーを、なくしてしまいました。
発行されたシークレットキーは再表示できない場合があるため、発行時に必ず控えておきます。控えていない場合は、新しいキーを発行し直してください。
どのファイルを、作成しますか?
GASエディタに、5つのファイルを作成します。app.gs(Webアプリとして表示する処理)、index.html(画面の構造)、style.html(CSS)、script.html(送信ボタンや履歴の描画)、main.gs(APIの呼び出しとスプレッドシート連携)です。
スプレッドシートのIDとシート名は、どこに書きますか?
ログ用のスプレッドシートを新規作成し、URL内のIDとシート名を控えます。それを、main.gsの冒頭にある設定変数に記述します。
使うモデルは、変更できますか?
変更できますが、掲載モデルには提供終了予定があります。
変更前に、index.htmlの直後にある期限と確認手順を確認してください。
