GASでAIの処理を
人の承認後に実行する方法

AIに文章を書かせる自動化で怖いのは、送信ボタンまで自動にしてしまうことです。 取り消せない操作の直前に人の確認を1か所だけ挟むHuman-in-the-loop(人が介在する設計)は、スプレッドシートのチェックボックスとトリガーだけで作れます。

|対象: GAS / Claude API / 承認フロー

Table of Contents

AI自動化を「全自動」にしてはいけない場面

AIの出力は、たまに大きく外します。 問題は精度そのものより、外したときに何が起きるかです。 下書きの生成なら直せば済みますが、送信・登録・支払いのように取り消せない操作まで自動化していると、事故がそのまま外に出ます。 判断基準はシンプルで、やり直しにコストがかかる操作の直前だけ、人の確認を挟むことです。

承認を挟むべき処理

社外メールの送信、請求・支払い、外部システムへの登録、公開コンテンツの投稿

自動で流してよい処理

下書きの作成、分類ラベルの付与、社内向けの集計通知、要約のシート追記

全自動の落とし穴

誤った宛先や金額でも、エラーにならないため誰も気づかないまま完了する

全件レビューの落とし穴

確認作業が残って工数が減らず、やがて中身を見ずに承認するだけになる

この記事では、AIがメールの下書きを作り、担当者がスプレッドシートのチェックボックスで承認し、承認された分だけを送信する流れを作ります。 専用の管理画面は作らず、GASの標準機能だけで完結させます。

承認フローをシートの列とステータスで設計する

承認フローの安定性は、コードよりもステータスの設計で決まります。 「未処理 → 承認待ち → 承認済み → 送信済み」と一方向に進む列を1つ持ち、すべての処理はこの値を見て動くようにします。 チェックボックスだけで判定すると、承認後に誰かがチェックを外した瞬間に状態が壊れます。

// A: 宛先 / B: 依頼内容 / C: AI下書き / D: 承認チェック / E: ステータス
// F: 承認者 / G: 承認日時 / H: 送信日時
const COL = { to: 1, request: 2, draft: 3, check: 4, status: 5, approver: 6, approvedAt: 7, sentAt: 8 };
const SHEET_NAME = "承認フロー";

const STATUS = {
  pending: "承認待ち",
  approved: "承認済み",
  sent: "送信済み",
  rejected: "却下",
  error: "エラー",
};

function setupSheet() {
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
  const header = ["宛先", "依頼内容", "AI下書き", "承認", "ステータス", "承認者", "承認日時", "送信日時"];
  sheet.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight("bold");
  sheet.setFrozenRows(1);

  // D列を承認用のチェックボックスにする
  sheet.getRange(2, COL.check, sheet.getMaxRows() - 1, 1).insertCheckboxes();
}

列番号をCOLにまとめておくと、後から列を追加したときの修正が1か所で済みます。 ステータスの文字列も定数にして、シートに直接ハードコードした文字と食い違わないようにします。

AIで下書きを作って承認待ちに置く

まずClaude APIを呼ぶ共通関数を用意します。APIキーはPropertiesService(スクリプトに設定値を安全に保存する仕組み)へ入れ、コードに直接書きません。

const API_KEY = PropertiesService.getScriptProperties().getProperty("CLAUDE_API_KEY");

function callClaude(payload) {
  const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages", {
    method: "post",
    contentType: "application/json",
    headers: {
      "x-api-key": API_KEY,
      "anthropic-version": "2023-06-01",
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });

  const code = res.getResponseCode();
  const body = JSON.parse(res.getContentText());
  if (code !== 200) {
    throw new Error("Claude API エラー (" + code + "): " + JSON.stringify(body));
  }
  return body.content.map((c) => c.text || "").join("");
}

下書き生成は「ステータスが空の行だけ」を対象にします。 こうしておけば、途中で実行時間制限に当たっても、次の実行が続きから再開します。 失敗した行はステータスをエラーにして、承認待ちの列に混ぜないことが重要です。

// ステータスが空の行だけAIで下書きを作り、「承認待ち」にする
function generateDrafts() {
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return;

  const rows = sheet.getRange(2, 1, lastRow - 1, COL.sentAt).getValues();

  rows.forEach((row, i) => {
    const rowNo = i + 2;
    const request = row[COL.request - 1];
    const status = row[COL.status - 1];
    if (!request || status) return; // 未処理の行だけ対象

    try {
      const draft = callClaude({
        model: "claude-sonnet-5",
        max_tokens: 1200,
        system: "あなたは日本語のビジネスメールを書く担当です。敬体で簡潔に、200字程度で本文のみを出力してください。",
        messages: [{ role: "user", content: request }],
      });

      sheet.getRange(rowNo, COL.draft).setValue(draft);
      sheet.getRange(rowNo, COL.status).setValue(STATUS.pending);
    } catch (e) {
      sheet.getRange(rowNo, COL.status).setValue(STATUS.error);
      sheet.getRange(rowNo, COL.draft).setValue("下書き生成に失敗: " + e.message);
    }
  });
}

チェックボックスとonEditで承認を受け取る

承認ボタンはD列のチェックボックスで代用します。 チェックされた瞬間にonEdit(e)が走るので、ここでステータスと承認者、承認日時を書き込みます。

重要な制約:関数名をonEditにするだけで動く「簡易トリガー」は権限が制限されており、メール送信や外部API呼び出しができません。 そのため、onEditの中ではシートに状態を書くだけにして、送信は別関数に分けます。

// 簡易トリガー。承認チェックを検知して「承認済み」に変えるだけに留める
function onEdit(e) {
  const range = e.range;
  const sheet = range.getSheet();
  if (sheet.getName() !== SHEET_NAME) return;
  if (range.getColumn() !== COL.check || range.getRow() < 2) return;

  const rowNo = range.getRow();
  const status = sheet.getRange(rowNo, COL.status).getValue();

  if (e.value === "TRUE") {
    // 承認待ち以外(送信済みなど)へのチェックは無視する
    if (status !== STATUS.pending) {
      range.setValue(false);
      return;
    }
    sheet.getRange(rowNo, COL.status).setValue(STATUS.approved);
    sheet.getRange(rowNo, COL.approver).setValue(Session.getActiveUser().getEmail());
    sheet.getRange(rowNo, COL.approvedAt).setValue(new Date());
  } else {
    // チェックを外したら承認前に戻す(送信後は戻せない)
    if (status === STATUS.approved) {
      sheet.getRange(rowNo, COL.status).setValue(STATUS.pending);
      sheet.getRange(rowNo, COL.approver, 1, 2).clearContent();
    }
  }
}

チェックを外したときの挙動も決めておきます。 上のコードでは、まだ送信していない承認済みなら承認待ちへ戻し、送信済みの行へのチェック操作は元に戻して無視します。 「後から取り消せる範囲」を明示するのが、承認フローの信頼性につながります。

承認済みだけを送信する(二重送信の防止)

送信処理は独立した関数にして、時間主導トリガー(一定間隔で自動実行する仕組み)で回します。 二重送信を防ぐ要点は3つです。

ロックを取る

LockServiceで同時実行を1つに制限し、前回が終わっていなければ何もしない

先に印を付ける

送信前にステータスと送信日時を書いてflush()で確定させる

送信済みを再確認

送信日時が入っている行は、ステータスに関わらず対象から外す

// 時間主導トリガー(例: 5分おき)で実行する。承認済みの行だけを送信する
function sendApproved() {
  const lock = LockService.getScriptLock();
  if (!lock.tryLock(10000)) return; // 前回の実行が終わっていなければ何もしない

  try {
    const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
    const lastRow = sheet.getLastRow();
    if (lastRow < 2) return;

    const rows = sheet.getRange(2, 1, lastRow - 1, COL.sentAt).getValues();

    rows.forEach((row, i) => {
      const rowNo = i + 2;
      if (row[COL.status - 1] !== STATUS.approved) return;
      if (row[COL.sentAt - 1]) return; // 送信日時が入っていれば送信済み

      const to = row[COL.to - 1];
      const draft = row[COL.draft - 1];
      if (!to || !draft) return;

      try {
        // 先にステータスを進めてからflushし、二重送信の窓を狭める
        sheet.getRange(rowNo, COL.status).setValue(STATUS.sent);
        sheet.getRange(rowNo, COL.sentAt).setValue(new Date());
        SpreadsheetApp.flush();

        GmailApp.sendEmail(to, "ご連絡", draft);
        appendAuditLog(rowNo, "送信", to);
      } catch (e) {
        sheet.getRange(rowNo, COL.status).setValue(STATUS.error);
        sheet.getRange(rowNo, COL.sentAt).clearContent();
        appendAuditLog(rowNo, "送信失敗: " + e.message, to);
      }
    });
  } finally {
    lock.releaseLock();
  }
}

送信前に印を付ける順序は意図的です。 送信してから記録しようとすると、記録の直前に実行時間制限やネットワークエラーで止まった場合、 「送ったのに未送信のまま残る」=次回に再送される事故が起きます。 先に印を付ける方式では、逆に「送っていないのに送信済みになる」可能性がありますが、 これは失敗時にステータスをエラーへ戻して気づける形にしておけば、二重送信より安全です。

誰がいつ承認したかを監査ログに残す

承認フローを業務に載せるなら、履歴を残しておきます。 スプレッドシートの変更履歴でもある程度は追えますが、行の削除や書式変更に埋もれて読みにくいため、専用のログシートに1行ずつ追記するほうが確実です。

function appendAuditLog(rowNo, action, target) {
  const ss = SpreadsheetApp.getActive();
  const log = ss.getSheetByName("監査ログ") || ss.insertSheet("監査ログ");
  if (log.getLastRow() === 0) {
    log.appendRow(["日時", "対象行", "操作", "宛先", "実行者"]);
  }
  log.appendRow([
    new Date(),
    rowNo,
    action,
    target,
    Session.getEffectiveUser().getEmail(),
  ]);
}

Session.getActiveUser()は操作した本人、Session.getEffectiveUser()はスクリプトを動かしている権限の持ち主を返します。 同じ組織のドメイン内であれば承認者のアドレスが取れますが、権限や共有設定によっては空文字になる場合があります。 空でも処理を止めないよう、取得できた場合だけ記録する前提で組んでください。

承認待ちの放置を防ぐリマインド通知

人を挟む設計の最大の弱点は、その人が押し忘れることです。 承認待ちが溜まったらまとめて1通だけ通知する関数を用意し、1時間おきの時間主導トリガーで回します。 1件ごとにメールを送ると通知が埋もれ、かえって見られなくなります。

const REMIND_AFTER_HOURS = 4;

// 承認待ちが溜まっていたら1通にまとめて通知する(例: 1時間おきに実行)
function remindPendingApprovals() {
  const sheet = SpreadsheetApp.getActive().getSheetByName(SHEET_NAME);
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return;

  const rows = sheet.getRange(2, 1, lastRow - 1, COL.sentAt).getValues();
  const limit = Date.now() - REMIND_AFTER_HOURS * 60 * 60 * 1000;

  const pending = rows
    .map((row, i) => ({ rowNo: i + 2, row: row }))
    .filter((r) => r.row[COL.status - 1] === STATUS.pending);

  if (pending.length === 0) return;

  // 依頼日時の代わりに下書きが入った行を対象にし、古い順で先頭3件を載せる
  const lines = pending.slice(0, 3).map((r) => r.rowNo + "行目: " + String(r.row[COL.request - 1]).slice(0, 40));
  const url = SpreadsheetApp.getActive().getUrl();

  MailApp.sendEmail({
    to: PropertiesService.getScriptProperties().getProperty("APPROVER_EMAIL"),
    subject: "【承認待ち " + pending.length + "件】AI下書きの確認をお願いします",
    body: [
      "承認待ちの案件が " + pending.length + " 件あります。",
      "",
      lines.join("\n"),
      "",
      url,
    ].join("\n"),
  });

  Logger.log("リマインド送信: " + pending.length + "件 (基準: " + new Date(limit) + " 以前)");
}

運用で決めておくこと

承認の期限、期限切れの扱い(自動で保留に落とすか、そのまま残すか)、承認者が不在のときの代理承認者。 この3つを先に決めておくと、承認待ちが数百行に膨らんで誰も見なくなる状態を避けられます。 通知先はコードに書かず、PropertiesServiceに置いて担当変更に耐えられるようにします。

AIの採点と組み合わせる

下書きをAIに採点させ、点数の低い案件を上に並べると、承認者は危ない案件から読めます。 ただし高得点を自動承認に回すのは、社内向け通知など影響の小さい処理に限定してください。

まとめ

AIを業務に載せるとき、精度を上げる前にやるべきなのは「外したときに止まる場所」を作ることです。 一方向に進むステータス列を設計し、AIは下書きまで、承認はチェックボックス、送信は別関数と役割を分ける。 onEditの簡易トリガーでは外部送信ができないという制約も、この分割で自然に解決します。 あとはLockServiceと送信日時による二重送信の防止、監査ログ、リマインド通知を足せば、レビューなしで回せる自動化になります。

よくある質問

自動処理の途中に人の判断を1か所だけ挟む設計のことです。AIに全部任せるのではなく、AIが下書きや判定を用意し、送信・登録といった取り消せない操作の直前で人が承認します。GASならスプレッドシートのチェックボックスが承認ボタン代わりになるため、専用の画面を作らずに実装できます。

簡易トリガー(関数名をonEditにするだけで動くトリガー)は権限が制限されており、メール送信や外部API呼び出しができません。承認チェックを受けた時点ではシートにステータスと承認者を書くだけにし、送信処理は別関数に分けます。その関数を時間主導トリガーで回すか、onEditを「インストール可能なトリガー」として登録し直すと権限の制限がなくなります。

ステータス列を必ず更新し、「承認済み」の行だけを処理して処理後に「送信済み」へ書き換えます。加えてLockService(同時実行を1つに制限する仕組み)でロックを取り、送信直前にステータスを再読み込みしてから送るのが確実です。承認された行の中には、送信済みか未送信かを判定できる印を必ず1つ持たせてください。

承認待ちのまま一定時間が過ぎた行を検出して、リマインドを飛ばす関数を時間主導トリガーで回します。件数と最も古い依頼日をまとめて1通にすると通知が埋もれません。期限を過ぎたら自動で「保留」に落として一覧から外す運用にすると、承認待ちが無限に積もるのを防げます。

社外に出る文章や金額が絡む処理では避けるのが安全です。現実的な折衷案は、AIのスコアが高い案件は承認画面での確認を簡略化し、低い案件だけ本文まで読ませる、といった優先順位付けに使う方法です。自動承認の範囲を広げるときは、まず対象を社内向けの通知など影響の小さい処理に限定してください。

承認時にセッションのメールアドレスと日時を専用列へ書き込み、あわせて別シートへ1行ずつ追記します。スプレッドシートの変更履歴でも追えますが、消えたり読みにくかったりするため、監査に使うなら自前のログシートを持つほうが確実です。

AI×GASの業務自動化を
相談する。

承認フロー付きのAI自動化の設計から、メール対応・書類作成の自動化まで、実務で安全に回せる仕組みをご相談いただけます。

AI×GAS業務自動化を見る