appscript.dev
Automation Intermediate Docs Sheets Drive

Extract action items from meeting notes

Pull checkboxed lines or `[ ]` bullets from notes Docs into a Tasks sheet.

Published Aug 3, 2025

Northwind’s meetings end the same way every time: someone scrolls through the notes Doc, reads the action items out loud, and promises to “drop them in the tracker later”. Later rarely happens. The actions sit buried in the Doc until the next standup, when someone re-asks who was meant to be doing what.

This script does the boring half. The notes follow a convention — every action is written as - [ ] @person task — so a regular expression can find them all. The script walks every Doc in the notes folder, picks out the unchecked bullets, and appends each one as a row in a Tasks sheet with owner, status, created date, and a stable ID so re-runs do not duplicate the same action.

What you’ll need

  • A Drive folder that holds the meeting notes Docs (one Doc per meeting).
  • A Google Sheet with five columns in the first tab: task, owner, status, created, id. Row 1 is headers, the script writes below.
  • A house style for actions in the notes: - [ ] @owner the action. Anything that does not match is treated as ordinary prose and ignored.

The script

// The Drive folder that holds meeting notes Docs.
const NOTES_FOLDER = '1abcNotesFolderId';

// The sheet that receives extracted action items.
const TASKS_SHEET = '1abcTasksSheetId';

// The unchecked-action pattern. Tweak if your house style differs.
//   - [ ] @owner the task
const ACTION_PATTERN = /^- \[\s\] @(\S+)\s+(.+)$/gm;
const ACTION_LINE = /^- \[\s\] @(\S+)\s+(.+)$/;

/**
 * Walks every notes Doc, extracts action items that look like
 * `- [ ] @owner the task`, and appends new ones to the Tasks sheet.
 */
function extractActionItems() {
  const tasks = SpreadsheetApp.openById(TASKS_SHEET).getSheets()[0];

  // Column E holds the stable ID for each task. Pull it once so we can
  // skip rows we have already imported.
  const seen = new Set(
    tasks.getRange('E2:E')
      .getValues()
      .flat()
      .filter(Boolean)
  );

  const files = DriveApp.getFolderById(NOTES_FOLDER).getFiles();
  const rows = [];

  while (files.hasNext()) {
    const file = files.next();

    // Only read actual Docs — skip PDFs, images, anything else in the folder.
    if (file.getMimeType() !== MimeType.GOOGLE_DOCS) continue;

    const body = DocumentApp.openById(file.getId()).getBody().getText();
    const matches = body.match(ACTION_PATTERN) || [];

    for (const m of matches) {
      const parsed = m.match(ACTION_LINE);
      if (!parsed) continue;
      const [, owner, task] = parsed;

      // Composite ID — same task in the same Doc is the same row, even if
      // it appears more than once in different sections.
      const id = file.getId() + '|' + task;
      if (seen.has(id)) continue;

      rows.push([task, owner, 'open', new Date(), id]);
      seen.add(id);
    }
  }

  if (rows.length) {
    tasks.getRange(tasks.getLastRow() + 1, 1, rows.length, 5).setValues(rows);
    Logger.log('Appended ' + rows.length + ' new action item(s).');
  } else {
    Logger.log('No new action items to append.');
  }
}

How it works

  1. extractActionItems opens the Tasks sheet and reads column E — the stable ID column — into a Set. That set is the de-dup index for the whole run.
  2. It iterates every file in the notes folder. Files that are not Google Docs are skipped, so a stray PDF or image in the folder does not throw.
  3. For each Doc, it pulls the full body text and runs the ACTION_PATTERN regex globally. The pattern matches lines that start with - [ ], an @owner, and the rest of the line as the task.
  4. Each match is parsed a second time with ACTION_LINE to capture the owner and task as groups. The composite ID is docId|task, which means the same action in the same Doc is identical across runs even if the line moves up or down the page.
  5. If the ID is already in seen, the script skips it. Otherwise it queues a five-column row: task, owner, status (always open on first import), created date, and the ID. The ID is added to seen so the same action appearing twice in one pass does not get inserted twice.
  6. Once every Doc is scanned, the queued rows are written in one batched setValues call below the existing data.

Example run

The notes folder contains 2025-05-21 Acme sync.gdoc with this section:

## Actions
- [ ] @priya draft pricing options for the call on Friday
- [x] @sam send the contract — already done
- [ ] @priya book the studio for next Tuesday

After the script runs, the Tasks sheet gains two rows:

taskownerstatuscreatedid
draft pricing options for the call on Fridaypriyaopen2025-05-21 09:00…AcmeNotes|draft pricing options...
book the studio for next Tuesdaypriyaopen2025-05-21 09:00…AcmeNotes|book the studio...

The [x] (already done) line is skipped because the regex demands a space between the brackets. On the next run, neither row is re-inserted because both IDs are already in column E.

Trigger it

Run it on a clock so notes captured during the day land in the tracker without anyone thinking about it.

  1. Open Triggers in the Apps Script editor.
  2. Add Trigger for extractActionItems, event source Time-driven, type Hour timer, interval Every hour.
  3. Approve the Drive, Docs, and Sheets scopes on the first run.

Hourly is plenty for meeting notes; daily is fine if the team writes actions in bursts at the end of the day rather than during the meeting.

Watch out for

  • The regex is strict. - [ ] needs exactly one space inside the brackets. Tabs, two spaces, or -[ ] will not match. Either keep the convention or loosen the pattern.
  • Owners must be a single token (@priya, not @Priya Patel). For full names, change (\S+) to (@[^\s]+) and strip the @ later, or use a delimiter your team agrees on.
  • Body.getText() flattens formatting, which is good — it means actions inside tables, bullet sublists, or quotes still match. It also means strike-through or highlight cues are lost, so treat the regex as the single source of truth.
  • The ID embeds the task text. If someone edits an action’s wording, the edited version is treated as a new row and the old one stays at open in the sheet. For long-lived tasks, prefer the append-only model and close stale rows by hand, or move to UUIDs written back into the Doc.
  • Status moves only forward in this script — it never closes a row when the [ ] becomes [x] in the Doc. Add a reconciliation pass if you want two-way sync.

Related