Build a Gemini Action Extractor

Build a CLI that turns meeting notes into validated Gemini action lists.

Introduction

30 Second Summary

Meeting notes can leave the next steps buried inside a page of discussion. Important tasks become easy to miss when owners or deadlines are unclear.

In this project, you will build a local Node.js command-line tool that turns a meeting-notes file into an action checklist. The tool pairs schema-constrained output from the Gemini Developer API with Zod validation before creating the final Markdown document.

What You'll Build

Running the CLI against meeting-notes.txt creates a todos.md checklist where every task shows its owner, due date, priority, and missing-detail warnings.

By the end of this project, you'll have:

  • A reusable CLI that processes another plain-text meeting-notes file without requiring source-code changes.
  • A validated AI workflow that rejects malformed or unexpected output before the application formats it.
  • A generated Markdown checklist that makes missing owners and missing deadlines visible to your team.
  • An optional Secret Mission to push your skills further.

Are there any prerequisites?

You need basic JavaScript knowledge plus comfort in the Terminal. You also need Node.js 20 or newer, Visual Studio Code, and a Gemini API key.

Step 1 installs the pinned project dependencies.

Before We Start

Before the hands-on work begins, lock in the tool you are building and why it matters. You are committing to a local Node.js CLI that turns meeting notes into a validated action checklist another person can trust and share.

Set Up the Local Project

Your local Node.js CLI needs a repeatable foundation before it can call the Gemini Developer API. A pinned dependency file makes the same SDK and validator versions install every time.

This step creates the project folder with the Google Gen AI SDK 2.31.0, Zod 4.6.7, and sample meeting notes. You'll also keep the API key in the current macOS terminal session so the secret never enters a project file.

In this step, get ready to:
  • Create the local project folder with its pinned dependency manifest.
  • Add the sample meeting notes before exporting the API key to the current terminal session.
  • Install the dependencies before confirming the generated installation artifacts.
Create the project folder and manifest

The package.json file is the dependency contract for your project. Its exact version pins protect the tutorial from changes in future package releases.

  • Press Cmd+Space to open the macOS search bar.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open Visual Studio Code.
  • Click File in the top menu bar.
  • Click Open Folder... to open the folder picker.

You'll see the macOS folder picker. This is where you create the project directly on your Desktop.

  • Select Desktop in the folder picker sidebar.
  • Click New Folder.
  • Enter meeting-notes-action-extractor as the folder name.
  • Click Open to load the folder in Visual Studio Code.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.

You'll see meeting-notes-action-extractor at the top of the Explorer sidebar. The folder is now your Visual Studio Code workspace.

  • Select the meeting-notes-action-extractor folder in the Explorer sidebar.
  • Click the New File icon at the top of the Explorer sidebar.
  • Enter package.json as the file name.
  • Add the pinned project manifest by pasting this code:
{
  "name": "meeting-notes-action-extractor",
  "version": "1.0.0",
  "private": true,
  "description": "Turn meeting notes into validated Markdown action items with Gemini.",
  "dependencies": {
    "@google/genai": "2.31.0",
    "zod": "4.6.7"
  }
}

What does this manifest do?

  • The name and description fields identify the local project.
  • The private setting marks the project as a private package.
  • The dependencies object pins the Google Gen AI SDK to 2.31.0 and Zod to 4.6.7.
  • Save package.json by pressing Cmd+S.
  • Confirm that package.json appears inside meeting-notes-action-extractor in the Explorer sidebar.

Does the manifest show a problem?

  • Check that the file is named package.json without an extra file extension.
  • Compare every brace and comma with the reference code below.
  • Ask for help if the editor still marks the JSON as invalid: Help me find the JSON syntax problem in my package.json file for a Node.js project.

✔️ Awesome, I've got everything!

Your saved package.json now defines the exact direct dependencies for this project.

ⓧ I'd like to double check the full code

Compare your complete package.json with this reference.

{
  "name": "meeting-notes-action-extractor",
  "version": "1.0.0",
  "private": true,
  "description": "Turn meeting notes into validated Markdown action items with Gemini.",
  "dependencies": {
    "@google/genai": "2.31.0",
    "zod": "4.6.7"
  }
}

This full-file reference matches the pinned manifest used throughout the project.

Add the notes and export the API key

A fixed notes file gives your first AI request a predictable input. It includes explicit tasks with several missing details so later steps can expose them clearly.

  • Select the meeting-notes-action-extractor folder in the Explorer sidebar.
  • Click the New File icon.
  • Enter meeting-notes.txt as the file name.
  • Add the sample meeting notes by pasting this text:
Website launch planning meeting

Maya will draft the landing-page copy by 2026-11-02. This is high priority.
Jordan will review the analytics tagging by Friday. This is medium priority.
Someone needs to choose three customer quotes for the homepage. No owner or due date was assigned.
Priya will schedule the launch email. The due date was not decided. This is medium priority.

Why use these meeting notes?

The notes contain four explicit tasks. They also include missing owners and missing due dates that your finished checklist needs to highlight.

The mix gives you a small input with enough variation to test extraction quality.

  • Save meeting-notes.txt by pressing Cmd+S.
  • Confirm that the editor shows the meeting title followed by four action-item sentences.

Do the notes look different?

  • Check that the blank line after the meeting title is present.
  • Confirm that each action-item sentence sits on its own line.
  • Ask for help if the saved text differs from the reference: Help me compare my meeting-notes.txt file with the expected sample notes.

✔️ Awesome, I've got everything!

Your saved meeting-notes.txt is ready to become the CLI's first input.

ⓧ I'd like to double check the full code

Compare your complete meeting-notes.txt with this reference.

Website launch planning meeting

Maya will draft the landing-page copy by 2026-11-02. This is high priority.
Jordan will review the analytics tagging by Friday. This is medium priority.
Someone needs to choose three customer quotes for the homepage. No owner or due date was assigned.
Priya will schedule the launch email. The due date was not decided. This is medium priority.

This full-file reference preserves the exact sample input used by the later extraction steps.

This credential step keeps the key local to your current terminal session. Your project files never contain the secret.

What is an environment variable?

An environment variable gives a running process access to a named value. Your JavaScript code can read GEMINI_API_KEY without embedding the credential in the source.

  • Click View in the Visual Studio Code menu bar.
  • Click Terminal to open the integrated terminal inside meeting-notes-action-extractor.
  • Replace YOUR_API_KEY in the command below with your real Gemini API key.
  • Export the key to the current terminal session by running the edited command:
export GEMINI_API_KEY="YOUR_API_KEY"

What does this command do?

The command stores the credential under GEMINI_API_KEY for processes started from this terminal session. Closing the session removes that exported value from future processes.

  • Confirm that the terminal returns to its prompt without reporting an error.

Did the export command fail?

  • Check that the command contains straight double quotes around the key.
  • Confirm that you replaced the placeholder without deleting the variable name.
  • Ask for help without sharing the credential: Help me correct my macOS export command without asking me to reveal my API key.
Install the pinned dependencies

The manifest now tells npm exactly which direct dependency versions the project needs. Installing them creates the local package folder and a lockfile that records the resolved dependency tree.

Before you run this, which two new dependency artifacts do you expect npm to create beside package.json?

  • Install the pinned project dependencies by running:
npm install

What does this command create?

  • npm reads the exact direct dependency versions from package.json.
  • The downloaded packages go into the local node_modules folder.
  • The generated package-lock.json file records the resolved installation details.

When the installation finishes, you'll see the terminal return to its prompt without an error. The Explorer sidebar now contains node_modules and package-lock.json beside your two saved files.

You have the project foundation locked in. The local dependencies now include @google/genai 2.31.0 and zod 4.6.7.

Did the installation fail?

  • Confirm that the integrated terminal is inside the meeting-notes-action-extractor folder.
  • Check that package.json matches the full-file reference above.
  • Check your internet connection before running the install command again.
  • Ask for help with the terminal output: Help me diagnose why npm cannot install this project's pinned dependencies.

Your dependencies, sample notes, and session-only API key are ready. Next, you'll make the first Gemini request and watch free-form prose reach a boundary your code cannot trust.

Get a Useful Answer You Cannot Trust

Your local Node.js project now has its pinned dependencies. The sample meeting notes are ready for your first request.

Now you'll ask Gemini to turn those notes into a Markdown checklist. A readable answer can still fail an application that expects structured fields.

In this step, get ready to:
  • Read a meeting-notes filename from a command-line argument.
  • Print a Gemini-generated checklist in the terminal.
  • Pass the response through a guard that exposes its unreliable shape.
Create the free-form extraction

A command-line argument lets someone choose which notes file the script reads. The fallback keeps the sample file available when no filename is supplied.

  • In Visual Studio Code, use the file sidebar's new-file icon to create index.mjs inside meeting-notes-action-extractor.
  • Paste this first runnable script into index.mjs:
import { readFile } from "node:fs/promises";
import process from "node:process";
import { GoogleGenAI } from "@google/genai";

async function main() {
  if (!process.env.GEMINI_API_KEY) {
    throw new Error("GEMINI_API_KEY is not set in this terminal session.");
  }

  const inputPath = process.argv[2] ?? "meeting-notes.txt";
  const notes = await readFile(inputPath, "utf8");
  const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

  const interaction = await ai.interactions.create({
    model: "gemini-3.8-flash",
    input: `Extract every explicit action item from these meeting notes as a Markdown checklist.\n\nMeeting notes:\n${notes}`,
  });

  console.log(interaction.output_text);
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});

What does this script do?

  • The imports provide promise-based file reading, access to the current process, and the official Gemini client.
  • The GEMINI_API_KEY check stops the request when the current terminal session has no key.
  • process.argv[2] holds the filename supplied after the script name. The fallback uses meeting-notes.txt.
  • readFile loads the notes as text using the utf8 encoding.
  • ai.interactions.create sends the notes to gemini-3.8-flash. The prompt asks for a human-readable checklist.
  • interaction.output_text contains the model's final text response.
  • Save index.mjs.

Before you run the script, how closely do you think the checklist will match the four actions in the notes?

  • Send the sample notes to Gemini by running this command in the terminal from earlier:
node index.mjs meeting-notes.txt

You'll see a Gemini-generated Markdown checklist based on the sample meeting notes. Its exact wording may vary between requests.

Your first AI extraction is working. The terminal now turns raw meeting notes into a readable action list.

Checklist missing or request failed?

  • Confirm the Terminal panel uses the same session where you exported GEMINI_API_KEY.
  • Check that meeting-notes.txt is inside the open meeting-notes-action-extractor folder.
  • Compare the imports and request fields with the code block above.
  • Help me diagnose why my Gemini meeting-notes request does not print a checklist.
Expose the formatter boundary

The terminal checklist is useful to a person. An application formatter needs named values with a predictable shape.

  • In index.mjs, place your cursor above async function main().
  • Add the guard by pasting this code:
function expectStructuredTasks(value) {
  if (typeof value === "string") {
    throw new Error("Expected structured tasks, received free-form text.");
  }

  return value;
}

What does this guard do?

  • typeof value === "string" detects a free-form text response.
  • The thrown error stops that response from crossing into code that expects structured tasks.
  • The return statement allows a future structured value to continue through the workflow.
  • In main, find console.log(interaction.output_text);.
  • Add this error-handling block directly below that line:
  try {
    expectStructuredTasks(interaction.output_text);
  } catch (error) {
    console.error(error.message);
  }

Why catch the error here?

  • The try block passes the response through expectStructuredTasks after the readable checklist has printed.
  • The catch block prints the deliberate failure without hiding the useful model response above it.
  • Save index.mjs.

Before you run the script again, do you think a readable checklist also satisfies the guard's demand for structured data?

  • Test the free-form response at the formatter boundary by running:
node index.mjs meeting-notes.txt

You'll first see another Markdown checklist. The final line says Expected structured tasks, received free-form text.

That failure is the result you wanted. Your script now proves that readable prose has no dependable shape for a data formatter.

Don't see the deliberate error?

  • Confirm the try block sits directly below console.log(interaction.output_text);.
  • Check that the guard compares typeof value with "string".
  • Confirm the call passes interaction.output_text into expectStructuredTasks.
  • Help me find why expectStructuredTasks does not reject my free-form Gemini response.

✔️ Awesome, I've got everything!

Your saved script now prints the checklist before exposing the formatter mismatch.

ⓧ I'd like to double check the full code

Your complete index.mjs should match this reference.

import { readFile } from "node:fs/promises";
import process from "node:process";
import { GoogleGenAI } from "@google/genai";

function expectStructuredTasks(value) {
  if (typeof value === "string") {
    throw new Error("Expected structured tasks, received free-form text.");
  }

  return value;
}

async function main() {
  if (!process.env.GEMINI_API_KEY) {
    throw new Error("GEMINI_API_KEY is not set in this terminal session.");
  }

  const inputPath = process.argv[2] ?? "meeting-notes.txt";
  const notes = await readFile(inputPath, "utf8");
  const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

  const interaction = await ai.interactions.create({
    model: "gemini-3.8-flash",
    input: `Extract every explicit action item from these meeting notes as a Markdown checklist.\n\nMeeting notes:\n${notes}`,
  });

  console.log(interaction.output_text);

  try {
    expectStructuredTasks(interaction.output_text);
  } catch (error) {
    console.error(error.message);
  }
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});

You now have a useful model response plus proof that your application cannot safely format it. Next, you'll replace the prose with schema-shaped data that your code can validate.

Validate Structured Gemini Output

Your last run proved that Gemini can read the meeting notes. The free-form checklist still failed at the formatter boundary.

Structured Outputs constrains Gemini's response to a JSON Schema. Zod then checks the parsed values inside your application.

In this step, get ready to:
  • Define the trusted shape for a meeting summary plus its action items.
  • Request schema-shaped JSON from Gemini with Structured Outputs.
  • Parse the response before validating every action item with Zod.
Define the trusted task shape

The schema below defines the exact object your application can trust. It requires a summary plus at least one action item.

  • In the Explorer sidebar in Visual Studio Code, select index.mjs.
  • Place your cursor below the GoogleGenAI import.
  • Add the Zod import plus the task schema by pasting this code:
import * as z from "zod";

const taskJsonSchema = {
  type: "object",
  properties: {
    summary: { type: "string", description: "A one-sentence summary of the meeting." },
    action_items: {
      type: "array",
      minItems: 1,
      items: {
        type: "object",
        properties: {
          task: { type: "string", description: "A concise action the team needs to complete." },
          owner: { type: "string", description: "The responsible person, or Unassigned when the notes do not name one." },
          due_date: { type: "string", description: "The due date exactly as written, or No due date when none is stated." },
          priority: { type: "string", enum: ["high", "medium", "low"], description: "The stated priority, or medium when the notes do not specify one." },
        },
        required: ["task", "owner", "due_date", "priority"],
        additionalProperties: false,
      },
    },
  },
  required: ["summary", "action_items"],
  additionalProperties: false,
};

const taskSchema = z.fromJSONSchema(taskJsonSchema);

What does this schema do?

  • The summary field stores one sentence about the meeting.
  • The action_items array requires at least one extracted task.
  • Each task requires an owner, a due date, plus one of three approved priority values.
  • The additionalProperties rules prevent unexpected fields from entering either object.
  • The taskSchema value turns the JSON Schema into a runtime parser with z.fromJSONSchema().
  • Save index.mjs.
  • Confirm the schema loads by running this command in the terminal from earlier:
node index.mjs meeting-notes.txt

You should still see a free-form checklist followed by Expected structured tasks, received free-form text. The familiar result confirms that the new schema loads before the request runs.

Seeing a syntax error before the request?

  • Check that the Zod import sits directly below the GoogleGenAI import.
  • Confirm every opening brace in taskJsonSchema has a matching closing brace.
  • Compare the four names in required with the four names inside properties.
  • Help me find the syntax error in my task schema.
Request schema-shaped JSON

The schema only becomes a model constraint when the request includes it. The revised prompt also defines the fallback values Gemini must use for missing details.

  • Locate the ai.interactions.create call inside main().
  • Replace the entire call with this schema-constrained request:
  const interaction = await ai.interactions.create({
    model: "gemini-3.8-flash",
    input: `Extract every explicit action item from these meeting notes.
Do not invent owners or due dates.
Use Unassigned when no owner is named.
Use No due date when no deadline is stated.
Use the stated priority, or medium when no priority is stated.

Meeting notes:
${notes}`,
    response_format: { type: "text", mime_type: "application/json", schema: taskJsonSchema },
  });

How does this request constrain Gemini?

  • The prompt assigns the schema-approved strings Unassigned plus No due date to missing details.
  • The response_format object asks for text containing structured JSON.
  • The mime_type value requests application/json.
  • The schema field supplies the contract Gemini must follow.
  • Save index.mjs.

Before you run this, predict whether the output will look like a Markdown checklist or a JSON object.

  • Test the schema-constrained request by running:
node index.mjs meeting-notes.txt

You should see a JSON object followed by Expected structured tasks, received free-form text. That second line is intentional because the temporary guard still rejects every string.

Why does the guard still fail?

The response now contains JSON text with a dependable shape. JavaScript still sees interaction.output_text as a string until your code parses it.

Still seeing a Markdown checklist?

  • Confirm response_format sits inside the object passed to ai.interactions.create.
  • Check that mime_type uses application/json.
  • Verify the request passes taskJsonSchema through the schema field.
  • Help me debug why Gemini is still returning a Markdown checklist.
Parse and validate the response

Parsing converts the returned JSON text into JavaScript values. Validation checks those values against the schema before the application uses them.

  • Delete the entire expectStructuredTasks() function near the top of index.mjs.
  • Replace the console.log(interaction.output_text) line plus its following try...catch block with this code:
  const parsedResponse = JSON.parse(interaction.output_text);
  const validatedResult = taskSchema.parse(parsedResponse);
  console.log(`Validated ${validatedResult.action_items.length} action items.`);

What happens at the trust boundary?

  • The JSON.parse() call converts the response string into JavaScript values.
  • The taskSchema.parse() call validates those values against the trusted task shape.
  • The final log reads the item count only from validatedResult.
  • Save index.mjs.

Before you run this, predict whether the script will reach the validated count or stop during parsing.

  • Verify the complete validation flow by running:
node index.mjs meeting-notes.txt

You should see Validated followed by a positive action-item count. The free-form formatting error is gone.

That is the reliability gap closed. Your CLI now counts action items only after schema validation succeeds.

Does parsing or validation stop the script?

  • Confirm JSON.parse() receives interaction.output_text.
  • Check that taskSchema.parse() receives parsedResponse.
  • Compare every name in the request schema with its matching required entry.
  • Help me debug a parsing or Zod validation failure.

✔️ Awesome, I've got everything!

Your saved index.mjs now requests structured JSON plus validates it before reading the action-item count.

ⓧ I'd like to double check the full code

  • Compare your saved index.mjs with this complete reference:
import { readFile } from "node:fs/promises";
import process from "node:process";
import { GoogleGenAI } from "@google/genai";
import * as z from "zod";

const taskJsonSchema = {
  type: "object",
  properties: {
    summary: { type: "string", description: "A one-sentence summary of the meeting." },
    action_items: {
      type: "array",
      minItems: 1,
      items: {
        type: "object",
        properties: {
          task: { type: "string", description: "A concise action the team needs to complete." },
          owner: { type: "string", description: "The responsible person, or Unassigned when the notes do not name one." },
          due_date: { type: "string", description: "The due date exactly as written, or No due date when none is stated." },
          priority: { type: "string", enum: ["high", "medium", "low"], description: "The stated priority, or medium when the notes do not specify one." },
        },
        required: ["task", "owner", "due_date", "priority"],
        additionalProperties: false,
      },
    },
  },
  required: ["summary", "action_items"],
  additionalProperties: false,
};

const taskSchema = z.fromJSONSchema(taskJsonSchema);

async function main() {
  if (!process.env.GEMINI_API_KEY) {
    throw new Error("GEMINI_API_KEY is not set in this terminal session.");
  }

  const inputPath = process.argv[2] ?? "meeting-notes.txt";
  const notes = await readFile(inputPath, "utf8");
  const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
  const interaction = await ai.interactions.create({
    model: "gemini-3.8-flash",
    input: `Extract every explicit action item from these meeting notes.
Do not invent owners or due dates.
Use Unassigned when no owner is named.
Use No due date when no deadline is stated.
Use the stated priority, or medium when no priority is stated.

Meeting notes:
${notes}`,
    response_format: { type: "text", mime_type: "application/json", schema: taskJsonSchema },
  });

  const parsedResponse = JSON.parse(interaction.output_text);
  const validatedResult = taskSchema.parse(parsedResponse);
  console.log(`Validated ${validatedResult.action_items.length} action items.`);
}

main().catch((error) => {
  console.error(error.message);
  process.exitCode = 1;
});

Your CLI now has a trusted data boundary around Gemini's response. Next up, you'll turn the validated tasks into a Markdown file that a teammate can use.

Generate the Markdown Deliverable

Your Gemini response now survives JSON parsing. Zod validation also confirms its shape.

The validated result currently disappears when the terminal session ends. This step turns that data into a Markdown checklist that a teammate can open and act on.

In this step, get ready to:
  • Add visible warnings for tasks with missing owners or due dates.
  • Convert the validated action items into a reusable Markdown document.
  • Handle validation failures separately from other runtime errors.
Add missing-detail warnings

The schema uses Unassigned and No due date when the notes omit important details. A warning helper converts those values into labels that readers can spot beside each task.

  • In Visual Studio Code, return to index.mjs.
  • Find the taskSchema declaration.
  • Add the warning helper below that declaration by pasting this code:
function warningFor(item) {
  const warnings = [];
  if (item.owner === "Unassigned") warnings.push("needs owner");
  if (item.due_date === "No due date") warnings.push("needs due date");
  return warnings.length > 0 ? ` [${warnings.join(", ")}]` : "";
}

What does this code do?

  • The warnings array collects every missing-detail label for one action item.
  • The owner check adds needs owner when the validated value is Unassigned.
  • The due-date check adds needs due date when the validated value is No due date.
  • The return value wraps any warnings in square brackets. A complete item receives an empty string.
  • Save index.mjs.
  • Confirm the helper has valid syntax by running this command in the terminal from earlier:
node index.mjs meeting-notes.txt

You should still see Validated followed by a positive action-item count. That confirms your new helper loads without breaking the validated workflow.

Seeing a syntax error?

  • Check that warningFor() sits above main().
  • Confirm each string uses matching quotation marks.
  • Check that the closing brace for warningFor() appears before main() begins.
  • Help me debug the warning helper in my meeting-notes action extractor.
Build the Markdown formatter

The formatter receives data only after validation succeeds. It can therefore build each checklist line from known fields without guessing at the response structure.

  • Replace the first import in index.mjs with this line so the script can write the finished file:
import { readFile, writeFile } from "node:fs/promises";

Why add writeFile?

The writeFile function is part of the promise-based file API in Node.js. It lets main() save the generated Markdown without blocking the rest of the process.

  • Find the closing brace of warningFor().
  • Add the Markdown formatter below that brace by pasting this code:
function toMarkdown(result, sourcePath) {
  const lines = ["# Meeting Action Items", "", `Source: \`${sourcePath}\``, "", `Summary: ${result.summary}`, "", "## Checklist", ""];
  for (const item of result.action_items) {
    lines.push(`- [ ] ${item.task}${warningFor(item)}`);
    lines.push(`  - Owner: ${item.owner}`);
    lines.push(`  - Due: ${item.due_date}`);
    lines.push(`  - Priority: ${item.priority}`);
  }
  return `${lines.join("\n")}\n`;
}

How does the formatter work?

  • The lines array starts the document with its heading. It also records the source filename and meeting summary.
  • The for...of loop turns every validated action item into a checkbox with owner metadata.
  • Each loop also adds the due date and priority on separate lines.
  • The final return joins the lines with newline characters. This produces one complete Markdown string.
  • Save index.mjs.
  • Confirm the formatter has valid syntax by running this command:
node index.mjs meeting-notes.txt

You should still see the validated action-item count. The script now loads both formatting helpers successfully.

Did the formatter introduce an error?

  • Check that every template literal uses matching backticks.
  • Confirm result.action_items uses an underscore between the two words.
  • Verify the formatter calls warningFor(item) inside the checkbox line.
  • Help me fix the toMarkdown formatter in index.mjs.

The formatter is ready. The main workflow now needs to pass it the validated result and save the returned string.

  • In main(), find const validatedResult = taskSchema.parse(parsedResponse);.
  • Replace the existing validation-count log below that line with this code:
  const markdown = toMarkdown(validatedResult, inputPath);
  await writeFile("todos.md", markdown, "utf8");
  console.log(`Created todos.md with ${validatedResult.action_items.length} action items.`);

What happens after validation?

  • The markdown constant holds the complete document produced from validated data.
  • The writeFile() call saves that document to todos.md with UTF-8 encoding.
  • The final log reports the generated filename and action-item count.
  • Save index.mjs.

Before you run the script, how many checklist items do you expect it to write from the sample notes?

  • Generate todos.md by running this command:
node index.mjs meeting-notes.txt

You should see Created todos.md with 4 action items. in the terminal. The file list in Visual Studio Code should now include todos.md.

Was todos.md not created?

  • Confirm the terminal is still inside the meeting-notes-action-extractor folder.
  • Check that the import contains both readFile and writeFile.
  • Verify the filename in writeFile() is exactly todos.md.
  • Help me find why my Node.js script does not create todos.md.
Handle failures and inspect the deliverable

A validation failure needs different debugging information from a missing key or unreadable file. The final error handler exposes Zod issues while keeping a clear message for every other failure.

  • Find the existing main().catch() block at the bottom of index.mjs.
  • Replace that block with this error handler:
main().catch((error) => {
  console.error("Could not create todos.md.");
  if (error instanceof z.ZodError) console.error(error.issues);
  else console.error(error.message);
  process.exitCode = 1;
});

How are failures separated?

  • The first message identifies the workflow that failed.
  • The instanceof z.ZodError check prints structured validation issues when the parsed data violates the schema.
  • The other branch reports messages from missing keys, unreadable files, malformed JSON, or API failures.
  • The process.exitCode assignment marks the command as failed while allowing error output to finish.
  • Save index.mjs.

Before the final run, what heading and task details do you expect to see inside the generated document?

  • Regenerate the deliverable by running this command:
node index.mjs meeting-notes.txt

You should see Created todos.md with 4 action items. with no error message.

  • Select todos.md from the file list in Visual Studio Code.

The document should begin with # Meeting Action Items. Below the source and summary, you should see checkbox tasks with owner, due-date, and priority lines.

Tasks with missing details should show needs owner or needs due date beside the checkbox. Your validated data is now a checklist another person can scan and use.

Does the document look incomplete?

  • Confirm the for...of loop sits inside toMarkdown().
  • Check that all four lines.push() calls sit inside the loop.
  • Verify the checkbox line calls warningFor(item).
  • Help me debug missing content in my generated todos.md file.

✔️ Awesome, I've got everything!

Your index.mjs file is saved. Running the CLI now regenerates todos.md from validated meeting notes.

ⓧ I'd like to double check the full code

Your finished index.mjs should match this reference.

import { readFile, writeFile } from "node:fs/promises";
import process from "node:process";
import { GoogleGenAI } from "@google/genai";
import * as z from "zod";

const taskJsonSchema = {
  type: "object",
  properties: {
    summary: { type: "string", description: "A one-sentence summary of the meeting." },
    action_items: { type: "array", minItems: 1, items: { type: "object", properties: { task: { type: "string", description: "A concise action the team needs to complete." }, owner: { type: "string", description: "The responsible person, or Unassigned when the notes do not name one." }, due_date: { type: "string", description: "The due date exactly as written, or No due date when none is stated." }, priority: { type: "string", enum: ["high", "medium", "low"], description: "The stated priority, or medium when the notes do not specify one." } }, required: ["task", "owner", "due_date", "priority"], additionalProperties: false } },
  },
  required: ["summary", "action_items"],
  additionalProperties: false,
};

const taskSchema = z.fromJSONSchema(taskJsonSchema);

function warningFor(item) {
  const warnings = [];
  if (item.owner === "Unassigned") warnings.push("needs owner");
  if (item.due_date === "No due date") warnings.push("needs due date");
  return warnings.length > 0 ? ` [${warnings.join(", ")}]` : "";
}

function toMarkdown(result, sourcePath) {
  const lines = ["# Meeting Action Items", "", `Source: \`${sourcePath}\``, "", `Summary: ${result.summary}`, "", "## Checklist", ""];
  for (const item of result.action_items) {
    lines.push(`- [ ] ${item.task}${warningFor(item)}`);
    lines.push(`  - Owner: ${item.owner}`);
    lines.push(`  - Due: ${item.due_date}`);
    lines.push(`  - Priority: ${item.priority}`);
  }
  return `${lines.join("\n")}\n`;
}

async function main() {
  if (!process.env.GEMINI_API_KEY) throw new Error("GEMINI_API_KEY is not set in this terminal session.");
  const inputPath = process.argv[2] ?? "meeting-notes.txt";
  const notes = await readFile(inputPath, "utf8");
  const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
  const interaction = await ai.interactions.create({ model: "gemini-3.8-flash", input: `Extract every explicit action item from these meeting notes.
Do not invent owners or due dates.
Use Unassigned when no owner is named.
Use No due date when no deadline is stated.
Use the stated priority, or medium when no priority is stated.

Meeting notes:
${notes}`, response_format: { type: "text", mime_type: "application/json", schema: taskJsonSchema } });
  const parsedResponse = JSON.parse(interaction.output_text);
  const validatedResult = taskSchema.parse(parsedResponse);
  const markdown = toMarkdown(validatedResult, inputPath);
  await writeFile("todos.md", markdown, "utf8");
  console.log(`Created todos.md with ${validatedResult.action_items.length} action items.`);
}

main().catch((error) => {
  console.error("Could not create todos.md.");
  if (error instanceof z.ZodError) console.error(error.issues);
  else console.error(error.message);
  process.exitCode = 1;
});

You now have a reusable CLI that turns validated AI output into a shareable task document.

Secret mission

Add a Triage Summary

Your checklist already flags missing details, but a long file can still hide the tasks that need attention first. Add a compact triage section that counts high-priority, unassigned, and undated tasks without another model call.

Clean Up Your Resources

Clean Up Your Resources

The application created no persistent cloud resources. Your local project files have no ongoing cost.

Resources you used:

  • The local meeting-notes-action-extractor project folder containing package.json, package-lock.json, node_modules, meeting-notes.txt, index.mjs, and todos.md.
  • The current macOS Terminal session with GEMINI_API_KEY exported.

Keep everything running

No action is needed. Choose this if you plan to process more meeting-notes files or continue improving the extractor.

  • Keep the current Terminal session open if you plan to rerun the CLI now.
  • Keep the meeting-notes-action-extractor folder unchanged so its pinned dependencies remain available.

Your API key remains available only to processes launched from the current Terminal session. Each new run makes another Gemini API call under the selected model's current pricing tier.

Pause - I'll come back to this later

Closing the current Terminal session removes the exported key from that shell. Your project files remain untouched.

  • Close the current macOS Terminal window to end the session.
  • Keep the meeting-notes-action-extractor folder so you can continue later.
  • Set GEMINI_API_KEY again in a new Terminal session when you return.

Delete - I don't want to use this again

Deleting this folder is irreversible. The generated checklist disappears with the source files.

  • Copy any version of todos.md that you want to keep to a location outside the project folder.
  • Switch back to the current Terminal session from earlier.
  • Move outside the project folder and delete it by running these commands:
cd ..
rm -rf meeting-notes-action-extractor

What Do These Commands Remove?

The first line moves the Terminal outside the project folder.

The second line permanently removes the meeting-notes-action-extractor folder. Its contents go with it.

  • Confirm that the project folder is gone by running:
ls

What Should I See?

You should no longer see meeting-notes-action-extractor in the output. That absence confirms the local project has been deleted.

Still See the Project Folder?

If the folder still appears, the delete command may have run from a directory that does not directly contain it.

Use this for targeted help: Help me safely locate and delete the meeting-notes-action-extractor folder on macOS without exposing my Gemini API key.

  • Close the current macOS Terminal window to remove GEMINI_API_KEY from that session.

Cleanup complete. Your local project folder is gone, and the session-only API key is no longer available.

Nice Work!

Nice Work!

You did it! You built a Node.js command-line tool that turns meeting notes into a validated action list with Gemini.

Your CLI now catches unreliable model output before it formats a shareable todos.md checklist.

What you learned:

  • Build a Node.js CLI that processes any supplied meeting-notes file.
  • Constrain Gemini responses with JSON Schema. Validate the parsed values with Zod before the formatter uses them.
  • Generate a reusable Markdown checklist in todos.md. Each task displays its owner. Each task also displays its due date plus priority. Missing details receive visible warnings.
  • Complete the optional Secret Mission by adding a deterministic triage summary. It counts high-priority tasks. It also counts unassigned tasks. Undated tasks receive their own count.

Ready to quiz yourself?