Spec-Driven Development with Gemini CLI

Build a URL metadata API using specs and constraints, not vibe coding.

Introduction

30 Second Summary

AI coding tools can write code in seconds, but anyone who has used them knows the output is wildly inconsistent. One prompt gives you clean code. The next gives you an unmaintainable mess.

In this project, you will build a URL metadata extraction API using Gemini CLI, and learn a repeatable spec-driven workflow that produces consistent, production-grade AI output every time.

What You'll Build

You will POST any URL to your running API and get back clean JSON with the page's title, description, HTTP status, and response time, all generated by an AI that followed your written specification to the letter.

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

  • A live API where you POST a URL and instantly receive structured metadata (title, description, status code, response time) in a consistent JSON envelope you defined upfront.
  • A reusable AGENTS.md constraint file that enforces your team's coding standards, forbidden patterns, and architectural decisions on every AI interaction.
  • A feature spec template that drives repeatable, high-quality code generation without you typing implementation code.
  • Tests that verify behavior as your primary quality gate, written before the AI generates the implementation.
  • Secret Mission: Add a caching layer with TTL expiry that the AI implements from a spec amendment you write yourself.

Are there any prerequisites?

This project assumes working knowledge of JavaScript and familiarity with REST APIs. You should have Node.js installed on your machine already.

Before We Start

Before we dive in, let's lock in what you're about to build and why the process matters just as much as the code.

Install Gemini CLI and Initialize Project

To build your URL metadata extraction API using spec-driven development, you need two things in place: the AI agent tool that will generate code from your specifications, and a clean project structure to work in.

In this step, you will install Gemini CLI globally, authenticate it with your Google account, and scaffold the project directory that holds your source code, tests, and agent configuration.

In this step, get ready to:
  • Install and authenticate Gemini CLI.
  • Create the project directory and install Express.
  • Set up the directory structure for source, tests, and agent configuration.
Install Gemini CLI and authenticate

Gemini CLI is an open-source AI coding agent from Google that runs in your terminal. It reads project context files and generates code based on your instructions. You will install it as a global npm package.

  • Install Gemini CLI globally by running this command:
npm install -g @google/gemini-cli

What does this command do?

The -g flag installs the package globally so you can run gemini from any directory on your machine. The package @google/gemini-cli is the official Gemini CLI published by Google.

  • Confirm the installation succeeded by checking the version:
gemini --version

You should see a version number printed in your terminal. This confirms Gemini CLI is installed and available.

Not seeing a version number?

If you see a "command not found" error, your npm global bin directory may not be in your PATH. Try closing and reopening your terminal, then run the command again.

If you see a SyntaxError mentioning "Unexpected token", your Node.js version may be too old. Gemini CLI requires Node.js 18 or higher. Run node --version to check.

help me fix my Gemini CLI installation

  • Now authenticate Gemini CLI with your Google account by running:
gemini

What happens during authentication?

On first run, Gemini CLI asks "How would you like to authenticate for this project?" and presents a list of options. You will select Sign in with Google. This opens a browser window where you sign in with your personal Google account.

Your credentials are cached locally after the first sign-in, so you will not need to repeat this flow every time you launch the CLI.

  • When prompted, select 1. Sign in with Google.
  • Complete the sign-in flow in your browser by selecting your Google account and clicking Sign in.
  • Return to your terminal. You should see the Gemini CLI chat interface ready to accept input.
  • Type /exit to close Gemini CLI for now. You will come back to it later.

Authentication failing?

If you see an error about GOOGLE_CLOUD_PROJECT, this usually means you have a leftover Google Cloud environment variable set. Individual Google accounts do not need this variable. Check your shell configuration and unset it if present.

help me fix my Gemini CLI authentication

Create the project directory and install Express

With Gemini CLI ready, you need a project folder to hold your API code. You will create this on your Desktop so it is easy to find.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir url-metadata-api
cd url-metadata-api
  • Initialize a new Node.js project by running:
npm init -y

This creates a package.json file with default values. You will see output confirming the file was written.

  • Install Express as your web framework by running:
npm install express
  • Confirm the install worked by checking your package.json:
cat package.json

You should see express listed under dependencies with a version starting with ^4.

npm install failing?

Make sure you are inside the url-metadata-api folder (check with pwd). The npm install command needs a package.json in the current directory.

help me fix my npm install issue

Create the directory structure

Your project needs three directories: one for source code, one for tests, and one for Gemini CLI's configuration files. Creating this structure upfront ensures the AI generates code into predictable locations.

  • Create the three directories by running:
mkdir src tests .gemini
  • Confirm the directories exist by running:
ls -a

You should see src, tests, .gemini, package.json, and node_modules in the output.

Why these three directories?

The src/ directory holds your application code. The tests/ directory keeps test files separate from production code.

The .gemini/ directory is where Gemini CLI looks for project-level configuration and agent definitions. You will use the project root for your AGENTS.md context file later, but the .gemini/ directory is ready for any subagent files you might create.

  • Open the url-metadata-api project in your code editor.

Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar. Type VS Code and press Enter to open it.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the url-metadata-api folder, and click Open.

You should see the src, tests, and node_modules folders in the Explorer sidebar. The .gemini folder may be hidden by default since it starts with a dot.

Before you move on, verify both tools are accessible. What do you expect to see when you run the version checks below?

  • Run these version checks to confirm your setup:
gemini --version
node --version

You should see a version number from each command. gemini --version prints the Gemini CLI version and node --version prints your Node.js version.

✔️ Awesome, I've got everything!

Great. Double check you have all three directories (src, tests, .gemini) and that express appears in your package.json dependencies.

ⓧ I'd like to double check the full code

Your package.json should look like this (the description, keywords, author, and license fields may vary):

{
  "name": "url-metadata-api",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "express": "^4.21.2"
  }
}

Your environment is ready and your project structure is in place. Next up, you will experience first-hand what happens when you ask an AI to build a feature with no specification or constraints.

Vibe Code It (The Unstructured Attempt)

Your project structure is in place and Gemini CLI is authenticated. Most developers start with AI coding tools by doing what feels natural: typing a casual prompt and shipping whatever comes back.

In this step, you'll deliberately use that approach. You'll give Gemini CLI a vague, one-line prompt with zero constraints, zero specs, and zero coding standards. The output will run, but it will not be production-ready. Feeling that gap firsthand is the entire point.

In this step, get ready to:
  • Give Gemini CLI an unstructured "vibe coding" prompt and accept whatever it generates.
  • Run the generated server and test it with curl.
  • Document the quality gaps you observe in the output.
Give Gemini CLI an unstructured prompt

"Vibe coding" means prompting an AI with a loose description and trusting whatever it produces. No input schema, no error cases, no response format. You'll do exactly that now and observe what happens.

  • Switch back to your terminal and make sure you're inside the url-metadata-api directory.
  • Start an interactive Gemini CLI session by running:
gemini

Gemini CLI opens an interactive chat session in your terminal. You type prompts and it responds, similar to a chat interface.

  • Type this prompt exactly as shown and press Enter:
Build me an Express API that takes a URL and returns its metadata. Save it to src/index.js.

What happens next?

Gemini CLI will generate code and then ask for permission to write the file. It shows you a diff of the proposed changes before writing anything.

Press y to confirm each file write. If it asks to run any shell commands (like installing packages), press y to approve those as well. Accept everything it generates without editing.

  • Wait for Gemini to finish generating and confirm all file write prompts by pressing y.
  • Once the generation is complete, exit the Gemini CLI session by pressing Ctrl+C or typing /quit.
  • Confirm the file was created by running:
ls src/

You should see index.js listed in the output.

Don't see index.js?

If the file wasn't created, Gemini may have saved it to a different path or you may have pressed n at the write confirmation prompt. Start a new gemini session and try the prompt again, making sure to press y when it asks to create the file.

help me get Gemini CLI to write code to src/index.js

Run the server and test with curl

Whatever Gemini generated is now sitting in src/index.js. You have no idea if it handles edge cases, validates input, or follows any consistent response format. Time to find out.

  • Start the server by running:
node src/index.js

You should see a message indicating the server is running on a port (likely 3000).

Seeing an error instead of a running server?

AI-generated code sometimes has bugs right out of the gate. That is part of what you're observing in this step. If the server crashes with a syntax error or missing module, note the error as one of your quality gaps. Then start a new gemini session and ask it to fix the error in src/index.js.

help me fix a crash in my AI-generated Express server

Before you test this, think for a moment. What response shape do you expect when you POST a URL to this server? Will it have consistent field names? Error handling? Input validation?

  • Open a new terminal tab or window (keep the server running in the first one).
  • Test the API by running:
curl -X POST http://localhost:3000/metadata -H "Content-Type: application/json" -d '{"url": "https://example.com"}'

Look at the JSON response you get back. Does it match any standard envelope format? Does it include all the fields you would want in a production API?

What should I look for?

Examine both the response AND the code in src/index.js. Common quality gaps from unstructured prompts include:

  • No input validation (what happens if you send an empty body or a non-URL string?).
  • No timeout on outbound HTTP requests (the server could hang forever).
  • Inconsistent response shape (success and error responses look completely different).
  • No JSDoc comments or clear function separation.
  • Console.log statements left in production code.
  • Try sending a request with no URL to see how it handles bad input:
curl -X POST http://localhost:3000/metadata -H "Content-Type: application/json" -d '{}'

Note what happens. Does it return a helpful error message with a proper status code? Or does it crash, return a stack trace, or give a generic 500?

Stop the server and document the gaps
  • Switch to the terminal where the server is running.
  • Press Ctrl+C to stop the server.

You just experienced why "vibe coding" produces prototypes, not production code. The problem is not the AI model. The problem is the absence of constraints and specifications.

Why did this happen?

You gave the AI no coding standards to follow, no error cases to handle, no response format to match, and no architectural decisions to respect. Without those constraints, the model does its best guess. Every time you run the same prompt, you get a different best guess.

The whitepaper principle this demonstrates: "Structure scales, vibes don't." Vibe coding is fine for throwaway prototypes. It fails the moment you need consistency, team standards, or production quality.

  • Open src/index.js in your editor and take 60 seconds to note every quality gap you can find.
  • Write down at least 3 specific problems with the generated code (missing validation, no error handling, no timeout, inconsistent naming, no comments, etc.).

You now have first-hand evidence that unstructured prompting produces inconsistent, under-specified output. In the next step, you'll create the two artifacts that transform this same AI model into a production code generator: a constraint file and a feature spec.

Write the Constraint File and Feature Spec

In the previous step, you saw what happens when you hand an AI a vague prompt. The output worked, but it was inconsistent, missing error handling, and far from production-ready. The problem was not the AI model. It was the absence of structure.

The whitepaper's core insight is that specification quality is now the bottleneck. In this step, you will create two artifacts that transform AI output from prototype to production: an AGENTS.md file that constrains the AI to your coding standards, and a feature spec that defines exactly what the implementation must do.

In this step, get ready to:
  • Create an AGENTS.md constraint file that encodes your project-wide coding standards, forbidden patterns, architecture, and testing rules.
  • Write a feature specification for the URL metadata endpoint with input/output schemas, error cases, and acceptance criteria.
  • Verify that Gemini CLI loads your constraints into its context automatically.
Create the AGENTS.md constraint file

AGENTS.md is a plain Markdown file that Gemini CLI loads automatically at the start of every session. Everything inside it becomes a persistent constraint the AI must follow.

  • Create a new file called AGENTS.md in the root of your url-metadata-api project folder (not inside any subdirectory).
  • Paste the following content into the file:
# Project Constraints

## Coding Standards
- Use JSDoc comments on all exported functions
- Use const/let, never var
- Use async/await, never raw callbacks
- All functions must return predictable types (no mixed return types)
- Name files in kebab-case, variables/functions in camelCase

## Forbidden Patterns
- No eval() or Function() constructor
- No fetch/http requests without a timeout (max 5000ms)
- No console.log in production code (use a logger or remove)
- No wildcard imports
- No mutation of function parameters

## Architecture
- Express router pattern: separate route handlers from business logic
- JSON response envelope: { success: boolean, data: object | null, error: string | null }
- All errors must be caught and returned as structured JSON, never crash the process
- Input validation at the route level before calling business logic

## Testing
- Every exported function must be testable in isolation
- Use Node.js built-in test runner (node:test)
- Tests define correctness; implementation must satisfy them

What does AGENTS.md do?

This file acts as a set of guardrails for the AI agent. Every constraint you list here applies to all code Gemini CLI generates in this project, across every session.

  • Coding Standards ensures consistent style (JSDoc, naming conventions, async patterns).
  • Forbidden Patterns prevents dangerous or sloppy code from ever appearing.
  • Architecture locks in the response envelope shape so every endpoint returns a predictable structure.
  • Testing tells the AI that tests define correctness, not the other way around.
  • Save AGENTS.md.
  • Confirm the file exists by running this in your terminal:
ls AGENTS.md

You should see AGENTS.md listed in the output.

Don't see AGENTS.md?

  • Make sure you saved the file in the project root (url-metadata-api/), not inside src/ or .gemini/.
  • Confirm your terminal is inside the url-metadata-api folder by running pwd.
  • help me find where AGENTS.md ended up in my project

Why AGENTS.md instead of GEMINI.md?

Gemini CLI loads both GEMINI.md and AGENTS.md as context files automatically. The AGENTS.md filename is the cross-tool standard, also supported by Codex, Kiro, Aider, and 20+ other tools. Using it means your constraint file is portable across AI tools, not locked to one vendor.

Write the feature specification

The constraint file tells the AI how to code. The feature spec tells it what to build.

  • Create the specs/ directory by running:
mkdir specs
  • Create a new file called specs/url-metadata.md inside the specs/ directory you just created.
  • Paste the endpoint definition, input schema, and output schema:
# Feature Spec: URL Metadata Extraction

## Endpoint
POST /metadata

## Input
JSON body:
- url (string, required): A valid HTTP or HTTPS URL

## Output (Success - 200)
JSON envelope:
{
  "success": true,
  "data": {
    "title": "string - the page's <title> content or null if missing",
    "description": "string - the meta description content or null if missing",
    "statusCode": "number - the HTTP status code of the fetched URL",
    "responseTimeMs": "number - time in milliseconds to fetch the URL",
    "fetchedAt": "string - ISO 8601 timestamp of when the fetch occurred"
  },
  "error": null
}

What does this section define?

This is the contract the implementation must satisfy. The output schema specifies the exact JSON shape every successful response must return.

  • title and description come from parsing the target page's HTML.
  • statusCode is the HTTP status the target URL returned.
  • responseTimeMs measures how long the fetch took.
  • fetchedAt records exactly when the data was retrieved.
  • Below the Output section in the same file, add the error cases and acceptance criteria:
## Error Cases
- Missing or empty url field: 400, error: "url is required"
- Invalid URL format (not http/https): 400, error: "url must be a valid HTTP or HTTPS URL"
- Target URL unreachable (DNS failure, connection refused): 502, error: "Unable to reach the provided URL"
- Target URL timeout (>5000ms): 504, error: "Request to the provided URL timed out"

## Acceptance Criteria
1. POST with valid URL returns 200 with all data fields present
2. POST with missing url returns 400 with appropriate error
3. POST with "not-a-url" returns 400 with format error
4. POST with unreachable domain returns 502
5. POST with extremely slow endpoint returns 504 after 5s

Why include acceptance criteria in the spec?

Acceptance criteria are test cases written in plain language. They give the AI an unambiguous definition of "done" for this feature.

Later, you will convert these into real test code. The AI cannot game or shortcut criteria that are this specific.

  • Save specs/url-metadata.md.
  • Confirm the file exists by running:
ls specs/

You should see url-metadata.md listed.

Don't see url-metadata.md?

  • If ls specs/ says "No such file or directory", you may have skipped the mkdir specs command. Run it first, then create the file inside the new directory.
  • Make sure you created the file inside specs/, not in the project root.
  • help me set up my specs directory and file

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Your specs/url-metadata.md file should contain two sections you pasted above, in this order:

  • Chunk 1: The heading, Endpoint, Input, and Output sections (ending with the closing } of the JSON envelope).
  • Chunk 2: The Error Cases and Acceptance Criteria sections (4 error cases, 5 numbered criteria).

Your AGENTS.md file (26 lines) should match the full block you pasted earlier. Scroll up and confirm the four sections are present: Coding Standards, Forbidden Patterns, Architecture, and Testing.

Verify Gemini CLI loads your constraints

Gemini CLI loads AGENTS.md automatically when it starts a session in your project directory. You can confirm this with a built-in command.

Before you run this, what do you think /memory show will display? Will it show your constraints, or will it be empty?

  • Start Gemini CLI by running:
gemini
  • Once the Gemini CLI session is active, type this command inside it:
/memory show

You should see the full contents of your AGENTS.md file displayed, including all four sections: Coding Standards, Forbidden Patterns, Architecture, and Testing. This confirms the agent is now operating under your constraints.

What does /memory show prove?

If your constraints appear in the output, it means every prompt you send in this session will be evaluated against those rules. The AI cannot generate code that violates them without you explicitly overriding.

This is the difference between hoping the AI follows best practices and guaranteeing it.

Memory showing empty?

  • Confirm AGENTS.md is in the project root (url-metadata-api/AGENTS.md), not inside a subdirectory. Gemini CLI only auto-loads context files from the root.
  • Make sure you started gemini from inside the url-metadata-api folder. Run pwd to check.
  • Try exiting the session (type /exit) and restarting with gemini from the correct directory.
  • help me get Gemini CLI to load my AGENTS.md file

Your constraints are loaded and your spec is written. Next up, you will give the AI the same task from Step 2, but this time with your spec and constraints in place. The difference in output quality will speak for itself.

Generate Production Code from the Spec

In Step 2 you gave Gemini CLI a casual prompt and got back inconsistent, production-unready code. In Step 3 you wrote the constraints and spec that define what production-quality means for this project.

Now you'll give the same AI model the same task, but this time with your AGENTS.md constraints loaded and your feature spec as the input. The only variable that changed is the quality of your input. This is the whitepaper's core thesis made tangible: structure scales, vibes don't.

In this step, get ready to:
  • Prompt Gemini CLI with a structured request that references your spec and constraints.
  • Run the generated server and test the happy path against your spec's response envelope.
  • Test error cases and compare the output quality against your unstructured attempt.
Prompt Gemini CLI with the structured request

Your AGENTS.md is already loaded into the agent's context from the previous step. Now you'll give it a prompt that points directly at your feature spec file.

  • Switch back to your Gemini CLI session (it should still be running from when you confirmed /memory show in the previous step).
  • Enter this prompt to generate the implementation from your spec:
Implement the feature described in specs/url-metadata.md. Follow all constraints in AGENTS.md. Generate the implementation in src/index.js and a router in src/routes/metadata.js.

Gemini CLI will read both files, then start generating code. For each file it creates or modifies, it presents a diff and asks you to confirm.

  • Review each diff carefully before accepting. Press y to confirm each file change.
  • The AI may create additional files (like a src/lib/fetcher.js for the fetch logic). Accept these too after reviewing them.

What should I look for before accepting?

Before pressing y on each diff, check that the generated code follows your AGENTS.md constraints:

  • JSON response envelope shape: { success, data, error } on every response.
  • Input validation at the route level (checking for missing or invalid URL before calling business logic).
  • No console.log calls (your forbidden patterns say to use stderr or remove them).
  • Separate route handler from business logic (Express router pattern).
  • A timeout on any HTTP request (max 5000ms, per your forbidden patterns).

Your output will differ from the reference code

AI-generated code varies between runs. Your file names, variable names, or internal structure may not match the reference code exactly. That is fine. What matters is that the behavior matches your spec: correct response shape, proper error codes, and timeout handling.

  • After accepting all changes, verify your package.json has a start script. Open package.json in your editor and check that the scripts section includes a start entry.
  • If the AI did not add it, add this line inside the "scripts" object in package.json:
"start": "node src/index.js"

Why add a start script?

The start script gives you a standard way to launch the server with npm start. It also signals to other developers (and deployment tools) which file is the entry point.

Run the server and test the happy path

Time to see if the structured approach produced better results. You'll run the exact same test you ran in Step 2.

  • Start the server by running:
node src/index.js

You should see output on stderr confirming the server started (something like Server running on port 3000).

  • Open a second terminal window. Before you run the next command, think: do you expect the response shape to match your spec's envelope format this time? Why or why not?
  • Test the happy path by running this curl command:
curl -X POST http://localhost:3000/metadata -H "Content-Type: application/json" -d '{"url": "https://example.com"'

You should see a response that matches your spec's envelope exactly:

{
  "success": true,
  "data": {
    "title": "Example Domain",
    "description": null,
    "statusCode": 200,
    "responseTimeMs": 150,
    "fetchedAt": "2026-08-03T12:00:00.000Z"
  },
  "error": null
}

What should I compare to Step 2?

Look at the structure. In Step 2 the AI probably returned a flat object, or missing fields, or no error handling. Now you have every field your spec defined: title, description, statusCode, responseTimeMs, and fetchedAt.

The wrapper envelope (success, data, error) is consistent. The same AI model produced dramatically different quality output because you gave it a spec and constraints.

Server won't start or returns unexpected output?

  • If you see a module-not-found error, the AI may have generated an import path that doesn't match the file it created. Check that the require paths in src/index.js point to files that actually exist.
  • If the response shape doesn't match the spec envelope, go back into Gemini CLI and tell it: "The response does not match the spec envelope. Fix it to return { success, data, error } on every response."
  • If port 3000 is already in use (from your Step 2 server), stop any running Node processes first with Ctrl+C in that terminal.

Ask for help: My spec-driven code isn't starting or the response format doesn't match my spec. Help me debug.

Test error cases and compare to the unstructured version

The real test of production code is how it handles failure. Your spec defines four error cases. Let's test two of them.

  • Test an invalid URL by running:
curl -X POST http://localhost:3000/metadata -H "Content-Type: application/json" -d '{"url": "not-a-url"}'

You should see a 400 response with the exact error message from your spec:

{
  "success": false,
  "data": null,
  "error": "url must be a valid HTTP or HTTPS URL"
}
  • Test an unreachable domain by running:
curl -X POST http://localhost:3000/metadata -H "Content-Type: application/json" -d '{"url": "https://this-domain-does-not-exist-xyz123.com"}'

You should see a 502 response:

{
  "success": false,
  "data": null,
  "error": "Unable to reach the provided URL"
}

Why is this better than Step 2?

In Step 2, the unstructured code likely crashed on invalid input, returned a raw error stack, or silently returned 200 with garbage data. Now every error path returns the same envelope structure with a meaningful message and the correct HTTP status code.

The AI model didn't change between steps. The structural difference is your spec and constraints. This is the whitepaper's principle in action: "AI amplifies your engineering culture." Strong standards produce strong output.

  • Stop the server in your first terminal by pressing Ctrl+C.

✔️ Awesome, my responses match the spec

Your spec-driven code produces the correct envelope, proper error codes, and handles edge cases. Save all files and move on.

ⓧ I'd like to double check the reference code

If your AI-generated code differs significantly or doesn't pass the tests above, here is what the key files should look like. You can use these as a reference to fix your generated output.

Your src/index.js should set up Express, mount the router, and include a global error handler:

const express = require('express');
const metadataRouter = require('./routes/metadata');

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());
app.use('/metadata', metadataRouter);

app.use((err, req, res, next) => {
  res.status(500).json({
    success: false,
    data: null,
    error: 'Internal server error'
  });
});

if (require.main === module) {
  app.listen(PORT, () => {
    process.stderr.write(`Server running on port ${PORT}\n`);
  });
}

module.exports = app;

Your src/routes/metadata.js should handle input validation and error responses:

const { Router } = require('express');
const { fetchMetadata } = require('../lib/fetcher');

const router = Router();

/**
 * POST /metadata
 * Extracts metadata from a provided URL.
 */
router.post('/', async (req, res) => {
  const { url } = req.body;

  if (!url || typeof url !== 'string' || url.trim() === '') {
    return res.status(400).json({
      success: false, data: null,
      error: 'url is required'
    });
  }

  let parsed;
  try {
    parsed = new URL(url);
  } catch {
    return res.status(400).json({
      success: false, data: null,
      error: 'url must be a valid HTTP or HTTPS URL'
    });
  }

The route handler continues with a protocol check and the fetch call:

  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
    return res.status(400).json({
      success: false, data: null,
      error: 'url must be a valid HTTP or HTTPS URL'
    });
  }

  try {
    const metadata = await fetchMetadata(url);
    return res.status(200).json({
      success: true, data: metadata, error: null
    });
  } catch (err) {
    if (err.code === 'TIMEOUT') {
      return res.status(504).json({
        success: false, data: null,
        error: 'Request to the provided URL timed out'
      });
    }
    return res.status(502).json({
      success: false, data: null,
      error: 'Unable to reach the provided URL'
    });
  }
});

module.exports = router;

Your src/lib/fetcher.js should use node:http and node:https with a 5000ms timeout:

const http = require('node:http');
const https = require('node:https');

const TIMEOUT_MS = 5000;

/**
 * Fetches metadata from a URL.
 * @param {string} url
 * @returns {Promise<{title: string|null, description: string|null, statusCode: number, responseTimeMs: number, fetchedAt: string}>}
 */
function fetchMetadata(url) {
  return new Promise((resolve, reject) => {
    const startTime = Date.now();
    const parsedUrl = new URL(url);
    const client = parsedUrl.protocol === 'https:' ? https : http;

    const req = client.get(url, { timeout: TIMEOUT_MS }, (res) => {
      let body = '';
      res.setEncoding('utf8');
      res.on('data', (chunk) => { body += chunk; });
      res.on('end', () => {
        const responseTimeMs = Date.now() - startTime;
        const title = extractTag(body, 'title');
        const description = extractMetaDescription(body);
        resolve({ title, description, statusCode: res.statusCode,
          responseTimeMs, fetchedAt: new Date().toISOString() });
      });
    });

The timeout and error handlers, plus the HTML parsing helpers:

    req.on('timeout', () => { req.destroy(); reject({ code: 'TIMEOUT' }); });
    req.on('error', (err) => {
      if (err.code === 'ECONNABORTED' || err.message === 'socket hang up') {
        reject({ code: 'TIMEOUT' });
      } else {
        reject(err);
      }
    });
  });
}

function extractTag(html, tag) {
  const regex = new RegExp(`<${tag}[^>]*>([^<]*)</${tag}>`, 'i');
  const match = html.match(regex);
  return match ? match[1].trim() : null;
}

function extractMetaDescription(html) {
  const regex = /<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["'][^>]*>/i;
  const match = html.match(regex);
  if (match) return match[1].trim();
  const altRegex = /<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["'][^>]*>/i;
  const altMatch = html.match(altRegex);
  return altMatch ? altMatch[1].trim() : null;
}

module.exports = { fetchMetadata };

Your spec-driven code handles happy paths, input validation, network errors, and timeouts. All from the same AI model that produced messy, inconsistent output in Step 2. Next up, you'll write tests that define correctness and use the AI to fill any remaining gaps.

Write Tests Then Verify with the AI

In the previous step you saw that a spec and constraint file transform AI output from prototype-grade to production-grade code. But how do you prove the code is correct?

The answer is tests. In a spec-driven workflow, tests become the main way you tell the agent what correct means. You write test cases that encode your spec's acceptance criteria, run them against the implementation, and let the AI fix any gaps. Your role is judge, not implementer.

In this step, get ready to:
  • Write test cases that encode the feature spec's acceptance criteria.
  • Run the tests and observe which ones fail.
  • Use Gemini CLI to fix any implementation gaps the tests catch.
Write the test harness and happy-path test

Your spec defines five acceptance criteria. You will encode each one as an automated test using node:test and node:assert.

  • Create the test file by opening tests/metadata.test.js in your editor.
  • Add the test harness that imports your app, starts the server on a random port, and shuts it down after the suite completes:
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert');
const app = require('../src/index');

let server;
let baseUrl;

// Start the server on a random port before tests run
before((context, done) => {
  server = app.listen(0, () => {
    const port = server.address().port;
    baseUrl = `http://localhost:${port}`;
    done();
  });
});

// Shut down the server after tests complete
after((context, done) => {
  server.close(done);
});

What does this code do?

The before hook starts your Express app on port 0, which tells the OS to pick any available port. This avoids conflicts if another process is using port 3000.

The after hook calls server.close() to cleanly shut down once all tests finish. Both hooks use callbacks (the done parameter) because server startup and shutdown are asynchronous.

  • Below the after hook, add the describe block with your first test case. This is the happy-path test that verifies a valid URL returns all required fields:
describe('POST /metadata', () => {
  it('returns 200 with all data fields for a valid URL', async () => {
    const res = await fetch(`${baseUrl}/metadata`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url: 'https://example.com' })
    });
    const json = await res.json();
    assert.strictEqual(res.status, 200);
    assert.strictEqual(json.success, true);
    assert.strictEqual(json.error, null);
    assert.ok('title' in json.data);
    assert.ok('description' in json.data);
    assert.ok('statusCode' in json.data);
    assert.ok('responseTimeMs' in json.data);
    assert.ok('fetchedAt' in json.data);
    assert.strictEqual(typeof json.data.statusCode, 'number');
    assert.strictEqual(typeof json.data.responseTimeMs, 'number');
  });
});

What does this test verify?

This test maps directly to acceptance criterion #1 from your spec: "POST with valid URL returns 200 with all data fields present."

It checks the status code, the envelope shape (success, data, error), and that every required field exists with the correct type. If the implementation drifts from your spec, this test catches it.

  • Save tests/metadata.test.js.
  • Run the happy-path test to confirm the test harness works:
npm test

You should see output showing 1 test passing. The test runner prints a checkmark next to the test name.

Test failing or hanging?

  • If you see a syntax error, check that the describe block is placed after the after hook, not inside it.
  • If the test hangs, make sure you called done() inside the app.listen callback and server.close(done) in the after hook.
  • If you see a network error, confirm you are not running another server on port 3000. Port 0 should avoid conflicts, but check for leftover processes.

Why isn't my test passing?

Add the error case tests

The happy path works. Now you need to encode the error cases from your spec: missing URL, invalid format, unreachable domain, and timeout.

  • In tests/metadata.test.js, find the closing }); of the describe block.
  • Add these two 400-error tests inside the describe block, just before that closing line:
  it('returns 400 when url is missing', async () => {
    const res = await fetch(`${baseUrl}/metadata`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({})
    });
    const json = await res.json();
    assert.strictEqual(res.status, 400);
    assert.strictEqual(json.success, false);
    assert.strictEqual(json.error, 'url is required');
  });

  it('returns 400 for invalid URL format', async () => {
    const res = await fetch(`${baseUrl}/metadata`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url: 'not-a-url' })
    });
    const json = await res.json();
    assert.strictEqual(res.status, 400);
    assert.strictEqual(json.success, false);
    assert.strictEqual(json.error, 'url must be a valid HTTP or HTTPS URL');
  });

What do these tests verify?

These map to acceptance criteria #2 and #3. Each test asserts the exact error message from your spec. If the implementation returns a different string or status code, the test fails.

Notice the pattern: each test sends a specific bad input and asserts the specific error response your spec mandates. The spec is the contract. The test is the enforcement.

  • Below the two 400-error tests you just added (still inside the describe block), add the network error tests:
  it('returns 502 for unreachable domain', async () => {
    const res = await fetch(`${baseUrl}/metadata`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url: 'https://this-domain-does-not-exist-xyz123.com' })
    });
    const json = await res.json();
    assert.strictEqual(res.status, 502);
    assert.strictEqual(json.success, false);
    assert.strictEqual(json.error, 'Unable to reach the provided URL');
  });

  it('returns 504 for timeout', async () => {
    // Uses a non-routable IP to trigger a timeout
    const res = await fetch(`${baseUrl}/metadata`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ url: 'http://10.255.255.1' })
    });
    const json = await res.json();
    assert.ok(res.status === 504 || res.status === 502);
    assert.strictEqual(json.success, false);
    assert.ok(json.error.length > 0);
  });

Why does the timeout test accept 502 or 504?

The non-routable IP 10.255.255.1 is designed to hang indefinitely, triggering a timeout. However, some networks or OS configurations may resolve it as unreachable instead. Accepting either 502 or 504 makes the test reliable across environments while still asserting the failure shape.

  • Save tests/metadata.test.js.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file and that all five it() blocks are inside the single describe block.

ⓧ I'd like to double check the full code

Your tests/metadata.test.js file should contain these sections in order:

  • Imports: node:test, node:assert, and ../src/index.
  • Variables: server and baseUrl.
  • The before hook starting the app on port 0.
  • The after hook closing the server.
  • A describe('POST /metadata') block containing five it() tests: 200 happy path, 400 missing url, 400 invalid format, 502 unreachable, 504 timeout.

Scroll up and confirm each chunk is present in your file, in this order.

Run the tests and observe failures

You have five tests that encode your spec's acceptance criteria. Before you run them, think about what you expect to happen.

  • Before you run this, predict: do you think all five tests will pass on the first try, or will some fail? Why?
  • Run the full test suite:
npm test

Watch the output carefully. The test runner prints a checkmark for passing tests and an X for failing ones.

What should I see?

If your spec-driven implementation from Step 4 is solid, all 5 tests pass green. This means the AI honored your spec precisely.

If any tests fail, that is the verification loop working exactly as designed. The tests caught a gap between what you specified and what the AI generated. Note which tests fail and what the error messages say.

Seeing timeout test take a long time?

The timeout test sends a request to a non-routable IP, which waits up to 5 seconds (your TIMEOUT_MS value in src/lib/fetcher.js) before failing. This delay is expected.

Why is my test suite taking so long?

Use Gemini CLI to fix any failing tests

If all tests passed, skip ahead to the validation below. If any tests failed, this is where the verification loop closes. You will hand the failures to the AI with a critical constraint: fix the implementation, not the tests.

  • Open Gemini CLI in your terminal by running:
gemini
  • Give it this prompt, pasting in your specific test failures:
These tests are failing: [paste your test failure output here]. Fix the implementation in src/routes/metadata.js to satisfy all test cases. Do not modify the tests.

Why say 'Do not modify the tests'?

Without this constraint, the AI might "fix" the problem by weakening your tests to match the buggy implementation. That defeats the entire purpose of verification-first development.

The tests encode your spec. The spec is the contract. The AI's job is to satisfy the contract, not rewrite it.

  • Review the changes Gemini suggests. Accept them if they align with your spec.
  • Exit Gemini CLI, then run the tests again:
npm test

All 5 tests should now pass. You should see checkmarks next to every test name and a summary showing 5 tests, 0 failures.

Still seeing failures after the fix?

  • Make sure you saved all changed files before re-running npm test.
  • Check that Gemini only modified files in src/ and did not touch tests/metadata.test.js.
  • Run Gemini CLI again with the new failures. Repeat until all tests pass.

Help me fix my remaining test failures.

You have just completed the full verification loop: write tests from a spec, run them against the implementation, then use the AI to close any gaps. This is the whitepaper's core insight in action. Tests define correctness. The AI satisfies them. You are the judge.

Next up, you will extend this working system by writing a spec amendment for a caching layer. No implementation code from you. Just a spec.

Secret mission

Add a Caching Layer via Spec Amendment

The real test of spec-driven development is whether you can extend a working system by writing a spec amendment, not by hacking code directly. Can you write a caching spec from scratch and have the AI implement it?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally and uses Gemini CLI's free tier, so there are no ongoing costs.

Resources you used:

  • The url-metadata-api project folder on your Desktop (contains all source code, specs, and tests).
  • Gemini CLI (globally installed npm package).

Keep everything running

No action needed. Choose this if you are still actively building or want to reference the project later.

  • Your project folder stays on your Desktop at ~/Desktop/url-metadata-api.
  • Gemini CLI remains available globally for future projects.
  • Your AGENTS.md and spec files are reusable templates you can copy into any new project.

Pause - I'll come back to this later

Stop any running processes to free up your terminal. Everything else stays in place for when you return.

  • If your server is still running, press Ctrl+C in the terminal to stop it.
  • If Gemini CLI is still open in an interactive session, type /quit or press Ctrl+C to exit.
  • Your project folder and Gemini CLI installation remain untouched.

Delete - I don't want to use this again

Remove all project files and optionally uninstall Gemini CLI.

  • Delete the project folder by running this command in your terminal (macOS/Linux):
rm -rf ~/Desktop/url-metadata-api
  • On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\url-metadata-api
  • Uninstall Gemini CLI globally by running:
npm uninstall -g @google/gemini-cli
  • Confirm it is removed by running gemini --version. You should see a "command not found" message.

That's a Wrap!

That's a Wrap!

Nice work. You just built a production-grade URL metadata extraction API without writing a single line of implementation code yourself.

You've learned how to:

  • Build a working Express API driven entirely by written specifications, using Gemini CLI as the implementation engine.
  • Create a reusable AGENTS.md constraint file that encodes your team's coding standards, forbidden patterns, and architectural decisions into every AI interaction.
  • Write verification-first tests that define correctness, then use the AI to satisfy them. Your role shifted from typist to judge.
  • Extended the system with an in-memory caching layer by writing a spec amendment. No implementation code, just intent.

Ready to quiz yourself?