You have learned what vibe coding is, how AI coding assistants work, what tools are available, how to set up your environment, and the Python fundamentals you need to read and understand code. Now it is time to put all of that knowledge into...
In This Chapter
- Learning Objectives
- Introduction
- 6.1 The Project: A CLI Task Manager
- 6.2 Planning the Application with AI
- 6.3 Generating the Core Data Model
- 6.4 Building the CLI Interface
- 6.5 Adding Persistence with JSON Storage
- 6.6 Implementing Search and Filtering
- 6.7 Adding Priority and Due Dates
- 6.8 Error Handling and Edge Cases
- 6.9 Testing Your Application
- 6.10 Reflecting on the Vibe Coding Process
- Connecting to What Is Next
- Summary
Chapter 6: Your First Vibe Coding Session
Learning Objectives
After completing this chapter, you will be able to:
- Remember the key steps in a vibe coding workflow from planning through testing (Bloom's Level 1)
- Understand how iterative conversation with an AI assistant produces working software (Bloom's Level 2)
- Apply prompt engineering techniques to generate a complete CLI application (Bloom's Level 3)
- Analyze the differences between vague and specific prompts by examining their effects on AI output quality (Bloom's Level 4)
- Evaluate AI-generated code for correctness, completeness, and adherence to Python best practices (Bloom's Level 5)
- Create a fully functional CLI task manager through an end-to-end vibe coding session (Bloom's Level 6)
Introduction
You have learned what vibe coding is, how AI coding assistants work, what tools are available, how to set up your environment, and the Python fundamentals you need to read and understand code. Now it is time to put all of that knowledge into practice.
In this chapter, you will build a complete command-line task manager application by having a conversation with an AI coding assistant. You will start with nothing more than an idea and end with a working program that can add, list, complete, delete, and search tasks, with data persisted to a JSON file on disk.
This is not a traditional programming tutorial where you copy code from a book. Instead, you will experience the full vibe coding workflow: planning with the AI, generating code through prompts, evaluating what the AI produces, iterating when something is not right, and reflecting on the process. Along the way, you will encounter realistic situations where the AI's first attempt is not quite right and you need to refine your request. These moments are not failures --- they are the normal rhythm of vibe coding, and learning to handle them is one of the most important skills you will develop.
Every prompt shown in this chapter is one you can type into your own AI assistant. The AI responses are paraphrased to capture the essential content, since your assistant's exact wording will differ. The resulting code, however, is provided in full so you can follow along, compare, and learn.
By the end of this chapter, you will have built something real --- something you could actually use every day. More importantly, you will have internalized the workflow that makes vibe coding productive. This workflow will serve you through every subsequent chapter in this book.
Let us begin.
6.1 The Project: A CLI Task Manager
Before opening your AI tool, take a moment to think about what you want to build. One of the most common mistakes new vibe coders make is jumping straight into prompting without a clear picture of their goal. Even a brief mental outline dramatically improves the quality of the conversation. The AI is a powerful collaborator, but it cannot read your mind. The clearer your vision, the better it can help.
Our project is a command-line task manager --- a program you run from the terminal that lets you manage a to-do list. Here is what it should do:
- Add a new task with a text description
- List all tasks, showing which are completed and which are pending
- Complete a task by marking it as done
- Delete a task you no longer need
- Search for tasks by keyword
- Persist tasks to a file so they survive between program runs
Why a Task Manager?
This is an ideal first project for several reasons. It is small enough to build in a single session, yet complex enough to exercise real programming concepts: data modeling, file I/O, command-line argument parsing, and error handling. Every one of these concepts was introduced in Chapter 5.
A task manager also has a clear, intuitive specification. You already know what a to-do list is and how it should behave, which means you can evaluate the AI's output against your own expectations. When the AI generates code for "add a task," you know exactly what that should look like from a user's perspective. This alignment between your mental model and the required behavior is what makes the project manageable for a first session.
Finally, a task manager is genuinely useful. When you finish this chapter, you will have a tool you can use daily. There is no better motivation for learning than building something you will actually use.
Note
You can follow along with any AI coding assistant --- Claude Code, GitHub Copilot Chat, Cursor, or any other tool from Chapter 3. The prompts and workflow translate across tools, though the exact responses will vary. If you are using Claude Code, you will be working in the terminal. If you are using Copilot Chat in VS Code, you will type your prompts in the chat panel. The principles are identical regardless of the interface.
What You Will Need
- Python 3.10 or later (installed in Chapter 4)
- A terminal or command prompt
- An AI coding assistant of your choice (configured in Chapter 4)
- A text editor or IDE (VS Code recommended)
Create a new directory for the project:
mkdir task-manager
cd task-manager
Now open your AI assistant and let us start building.
6.2 Planning the Application with AI
The first prompt in any vibe coding session should be a planning prompt. Rather than asking the AI to immediately write code, ask it to help you think through the design. This serves two critical purposes:
-
It gives the AI context about the entire project. When the AI understands the full scope of what you are building, every piece of code it generates will be more consistent and coherent. Without this context, each prompt exists in isolation, and the AI may make design decisions in one piece of code that conflict with what you need elsewhere.
-
It gives you a chance to evaluate the AI's understanding. Before committing to implementation, you can check whether the AI's proposed architecture makes sense. It is much cheaper to fix a design problem at the planning stage than after you have written 200 lines of code.
The Opening Prompt
Here is the first prompt we send to the AI:
I want to build a command-line task manager in Python. It should let me add
tasks, list them, mark them as complete, and delete them. Tasks should be
saved to a file so they persist between sessions. Can you help me plan this
out? What features should I include and how should I structure the code?
Notice the structure of this prompt. It provides: - The goal: a command-line task manager - The language: Python - The core features: add, list, complete, delete - A non-functional requirement: persistence between sessions - An open-ended question: asking the AI to suggest features and structure
The open-ended question is deliberate. We want the AI's perspective on architecture before we start constraining the implementation.
What the AI Suggests
The AI responds with a project plan that includes:
- Data model: A
Taskclass (or dataclass) with fields for ID, description, completion status, and timestamps - Storage: JSON file-based persistence using Python's built-in
jsonmodule - CLI interface: Command-line argument parsing with
argparseand subcommands - Additional feature suggestions: Search, filtering by status, priority levels, due dates, and categories
The AI also suggests a code structure with three logical layers:
- The data model (the Task dataclass)
- The storage layer (functions to load and save tasks)
- The CLI layer (argument parsing and command dispatch)
This three-layer architecture is a good sign. It shows the AI is thinking about separation of concerns --- keeping different responsibilities in distinct parts of the code. Even though everything will live in a single file for now, having these logical layers will make the code easier to read and modify.
Best Practice: Always start your vibe coding session with a planning prompt. It costs you one extra message but dramatically improves the coherence of all subsequent code the AI generates. The AI performs better when it understands the full picture. Think of it like briefing a contractor before they start work: the five minutes you spend explaining the project saves hours of rework later.
Narrowing the Scope
The AI's suggestion is solid, but it includes more features than we need for our first pass. A key vibe coding skill is knowing when to constrain the AI's ambitions. AI assistants are eager to please and will often suggest an impressive list of features. Your job is to decide what to build now and what to defer to later.
We send a follow-up:
That plan looks good. Let's keep it simple for now: just add, list,
complete, and delete. We can add search later. Can you list out the
specific Python files and functions we will need?
The AI responds with a concrete function inventory:
Taskdataclass withid,description,completed,created_at, andcompleted_atfieldsload_tasks()--- read tasks from JSON filesave_tasks()--- write tasks to JSON fileget_next_id()--- calculate the next available task IDadd_task()--- create a new tasklist_tasks()--- retrieve tasks with optional filteringcomplete_task()--- mark a task as donedelete_task()--- remove a taskbuild_parser()--- set up the argparse CLImain()--- entry point that dispatches commands
The AI recommends keeping everything in a single file for simplicity, which is appropriate for a project of this size. For larger projects (covered in Chapter 13), you would split into multiple files, but for now, one file keeps things manageable.
Intuition: Notice how the second prompt narrowed the scope. This is a common pattern in vibe coding: the AI often suggests more than you need. Your job is to be the decision-maker about what to build now and what to defer. Think of it as being the product manager while the AI is the developer. The product manager decides what to build; the developer figures out how to build it.
We now have a clear plan: ten functions in one file, organized into three layers. Let us start building.
6.3 Generating the Core Data Model
With the plan established, we move to generating actual code. The first piece is the data model --- the Task dataclass that represents a single task. We start here because everything else depends on it: the storage layer needs to know what to save, the CLI needs to know what to display, and the operations need to know what fields to modify.
The Data Model Prompt
Let's start with the data model. Create a Python dataclass called Task
with fields for id (int), description (str), completed (bool, default
False), and created_at (str, default to current ISO timestamp). Use type
hints and add a docstring.
This prompt is a specification prompt --- it tells the AI exactly what to produce, including specific field names, types, and defaults. Specification prompts are ideal when you know precisely what you want. They produce predictable results because they leave little room for the AI to make its own design decisions.
What the AI Produces
The AI generates:
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
@dataclass
class Task:
"""Represents a single task in the task manager.
Attributes:
id: Unique integer identifier for the task.
description: Text description of what needs to be done.
completed: Whether the task has been marked as done.
created_at: ISO-format timestamp of when the task was created.
"""
id: int
description: str
completed: bool = False
created_at: str = field(
default_factory=lambda: datetime.now().isoformat()
)
Let us evaluate this code against what we asked for:
- Field names and types: Match exactly what we specified. The
idis anint,descriptionis astr,completedis aboolwith a default ofFalse. - Timestamp default: Uses
field(default_factory=lambda: datetime.now().isoformat()). This is necessary because mutable or dynamically computed defaults in dataclasses require a factory function. If you recall from Section 5.11, a plain default value likecreated_at: str = datetime.now().isoformat()would be evaluated once at class definition time, and every task would share the same creation timestamp. The factory ensures a fresh timestamp for each newTask. - Docstring: Clear, descriptive, documents each attribute.
- Type hints: Present on every field.
- Code style: Clean, PEP 8 compliant, properly formatted.
This is exactly what we wanted. The AI nailed it on the first try because our prompt was specific enough to leave no ambiguity.
Iterating: Adding a Missing Field
Looking at the code, we realize we also want to track when a task was completed. This was not in our original prompt, but it is a natural addition once we see the data model taking shape. This is normal --- you often do not think of everything upfront, and that is perfectly fine. Time to iterate:
Good, but I also want a completed_at field that is None by default and
gets set when the task is marked complete. Can you update the Task
dataclass?
The AI adds:
completed_at: Optional[str] = None
and updates the docstring to include the new field. The Optional[str] type hint (from Section 5.11) indicates that this field can be either a string or None. It starts as None and gets set to an ISO timestamp when complete_task() is called.
Common Pitfall: New vibe coders sometimes feel they need to specify everything perfectly in the first prompt. You do not. It is completely normal --- and expected --- to iterate. Asking for small modifications is often easier and more reliable than trying to specify every detail upfront. The AI maintains context from previous messages, so it knows you are refining the same piece of code.
Understanding What We Built
The final Task dataclass has five fields:
| Field | Type | Default | Purpose |
|---|---|---|---|
id |
int |
(required) | Unique identifier |
description |
str |
(required) | What needs to be done |
completed |
bool |
False |
Whether it is done |
created_at |
str |
Current timestamp | When it was created |
completed_at |
Optional[str] |
None |
When it was completed |
This is our core data model, and everything else in the application will build on it. The required fields (id and description) come first, followed by fields with defaults. This ordering is a Python requirement for dataclasses --- fields without defaults must precede fields with defaults, or Python will raise a TypeError.
6.4 Building the CLI Interface
With the data model in place, we need a way for users to interact with the application from the terminal. Python's argparse module (covered in Section 5.13) is the standard tool for building command-line interfaces. It handles parsing command-line arguments, generating help text, and validating input types.
The CLI Prompt
Now build the CLI interface using argparse. I want these subcommands:
- add <description>: Add a new task
- list: Show all tasks
- complete <id>: Mark a task as done
- delete <id>: Remove a task
Include a main() function that parses arguments and calls the appropriate
handler. Use proper help text for each command.
This is another specification prompt. By listing each subcommand with its arguments and a description, we give the AI a precise blueprint to follow.
What the AI Produces
The AI generates a build_parser() function that creates an ArgumentParser with four subparsers, one for each command:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="task-manager",
description="A simple CLI task manager built with vibe coding.",
)
subparsers = parser.add_subparsers(dest="command", help="Available commands")
# add
add_parser = subparsers.add_parser("add", help="Add a new task")
add_parser.add_argument("description", type=str, help="Task description")
# list
subparsers.add_parser("list", help="List tasks")
# complete
complete_parser = subparsers.add_parser("complete", help="Mark a task as done")
complete_parser.add_argument("id", type=int, help="Task ID to complete")
# delete
delete_parser = subparsers.add_parser("delete", help="Delete a task")
delete_parser.add_argument("id", type=int, help="Task ID to delete")
return parser
Let us walk through this code. The add_subparsers(dest="command") call creates a container for subcommands. The dest="command" parameter means that after parsing, args.command will contain the name of the chosen subcommand ("add", "list", "complete", or "delete"). Each add_parser() call defines one subcommand and its arguments. For example, the add command takes a positional description argument of type str, while complete takes a positional id argument of type int.
The AI also generates a main() function that dispatches to the correct handler based on args.command. This function is the heart of the CLI --- it is the bridge between what the user types in the terminal and the task management functions we will build next.
Note
If you run the application now with python task_manager.py --help, argparse automatically generates help text from the descriptions we provided. This is one of the advantages of using argparse over manually parsing sys.argv --- you get help text, error handling for wrong argument types, and usage messages for free.
Improving the Display
After running the application with a few test tasks, the output is functional but plain. The AI's default list implementation simply prints each task's repr(), which looks something like Task(id=1, description='Buy groceries', completed=False, ...). Not exactly user-friendly. Let us make it better:
The list command output is hard to read. Can you add a format_task()
helper that shows each task like this:
1. [x] Buy groceries
2. [ ] Write chapter 6
And add a header and a count of total/completed/pending at the bottom.
This is an example-driven prompt. Instead of describing the output format in words ("show the task ID, then a space, then a period, then brackets with an x if completed..."), we simply show the AI what we want. This is one of the most effective prompting techniques, especially for display and formatting tasks.
Best Practice: Showing the AI a concrete example of desired output is one of the most effective prompting techniques for formatting. The AI can reverse-engineer exactly what you want from the example. It is like showing a carpenter a picture of the bookshelf you want instead of describing it in words.
The AI produces two functions --- format_task() and display_tasks() --- that produce nicely formatted output:
def format_task(task: Task) -> str:
status_icon = "[x]" if task.completed else "[ ]"
return f" {task.id:>3}. {status_icon} {task.description}"
def display_tasks(tasks: list[Task], header: str = "Tasks") -> None:
if not tasks:
print("No tasks found.")
return
print(f"\n{header}")
print("-" * 50)
for task in tasks:
print(format_task(task))
print(f"\nTotal: {len(tasks)} task(s)")
completed_count = sum(1 for t in tasks if t.completed)
pending_count = len(tasks) - completed_count
print(f" Completed: {completed_count} | Pending: {pending_count}")
Several Python features from Chapter 5 appear here:
- Ternary expression:
"[x]" if task.completed else "[ ]"(Section 5.3) - f-string formatting:
{task.id:>3}right-aligns the ID in a 3-character field (Section 5.6) - Generator expression:
sum(1 for t in tasks if t.completed)counts completed tasks without creating an intermediate list (Section 5.12) - Default parameter:
header: str = "Tasks"provides a sensible default while allowing customization (Section 5.4)
Now when we run python task_manager.py list, we get clean, readable output:
Tasks
--------------------------------------------------
1. [ ] Buy groceries
2. [ ] Write chapter 6
3. [x] Set up Python environment
Total: 3 task(s)
Completed: 1 | Pending: 2
6.5 Adding Persistence with JSON Storage
Our task manager needs to save tasks to disk so they persist between program runs. Without persistence, all tasks would vanish when the program exits --- not very useful for a to-do list.
We will use JSON (JavaScript Object Notation) for storage. JSON is human-readable, natively supported by Python's standard library, and a natural format for the kind of structured data our Task dataclass represents.
The Persistence Prompt
Now add JSON file persistence. Write load_tasks() and save_tasks()
functions that read from and write to a tasks.json file. Use the
dataclasses.asdict() function to serialize tasks. Handle the case where
the file does not exist yet.
What the AI Produces
from dataclasses import asdict
import json
from pathlib import Path
DEFAULT_DATA_FILE = "tasks.json"
def load_tasks() -> list[Task]:
path = Path(DEFAULT_DATA_FILE)
if not path.exists():
return []
with open(path, "r", encoding="utf-8") as fh:
raw_data = json.load(fh)
return [Task(**item) for item in raw_data]
def save_tasks(tasks: list[Task]) -> None:
path = Path(DEFAULT_DATA_FILE)
with open(path, "w", encoding="utf-8") as fh:
json.dump([asdict(t) for t in tasks], fh, indent=2)
This code uses several concepts from Chapter 5:
pathlib.Pathfor file paths (Section 5.7), which provides clean, cross-platform path handlingjson.load()andjson.dump()for serialization (Section 5.13), which convert between Python objects and JSON textdataclasses.asdict()to convertTaskinstances to dictionaries (Section 5.11), which is exactly the format thatjson.dump()needs- Dictionary unpacking with
Task(**item)to reconstructTaskobjects from the stored data (Section 5.5) --- each dictionary key becomes a keyword argument to theTaskconstructor
The indent=2 parameter in json.dump() formats the JSON with two-space indentation, making the file human-readable. Without it, all data would be on a single line, which is functionally identical but impossible to debug visually.
Understanding the Serialization Round-Trip
Let us trace what happens during a save-and-load cycle:
-
Save: A
Task(id=1, description="Buy groceries", completed=False, created_at="2025-03-15T10:30:00", completed_at=None)is converted byasdict()to{"id": 1, "description": "Buy groceries", "completed": false, "created_at": "2025-03-15T10:30:00", "completed_at": null}, then written to disk byjson.dump(). -
Load:
json.load()reads the file and produces a Python dictionary.Task(**item)unpacks it:Task(id=1, description="Buy groceries", completed=False, created_at="2025-03-15T10:30:00", completed_at=None). The round-trip is lossless.
Advanced: This round-trip works because our
Taskdataclass only uses types that JSON natively supports:int,str,bool, andNone. If we had useddatetimeobjects instead of ISO-format strings, we would need custom serialization. Using strings for timestamps is a deliberate design choice that simplifies persistence.
Thinking About Safety
After looking at the code, we think about what could go wrong. This is a critical step in the vibe coding workflow --- the evaluate phase. What if the program crashes while writing the file? The half-written file would corrupt our data, potentially losing everything.
Let us ask the AI to fix that:
I am worried about data loss if the program crashes while writing the
file. Can you update save_tasks() to write to a temporary file first and
then rename it? That way, if the write fails, the original file is not
corrupted.
This is a constraint-based prompt --- it identifies a specific problem (data corruption during writes) and specifies a solution approach (write to temp file, then rename).
The AI updates save_tasks() to use an atomic write pattern:
def save_tasks(tasks: list[Task]) -> None:
path = Path(DEFAULT_DATA_FILE)
tmp_path = path.with_suffix(".tmp")
try:
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump([asdict(t) for t in tasks], fh, indent=2)
tmp_path.replace(path)
except OSError as exc:
print(f"Error: Could not save tasks to {path}: {exc}")
if tmp_path.exists():
tmp_path.unlink()
The key insight is tmp_path.replace(path). On most operating systems, a file rename is an atomic operation --- it either completes fully or does not happen at all. By writing to a temporary file (tasks.tmp) first and then renaming it to the real file (tasks.json), we ensure the original file is never left in a partially written state. If the write fails (disk full, permission denied, program crash), the original tasks.json remains intact. The except block also cleans up the temporary file if the write fails.
Intuition: This is an example of the AI producing correct but incomplete code on the first pass. The original
save_tasks()was functionally correct for normal use, but lacked protection against edge cases. The AI did not add this safety measure on its own --- we had to ask for it. This illustrates a key vibe coding principle: you are responsible for thinking about robustness; the AI implements what you ask for. The AI is excellent at writing code, but it is not a substitute for your judgment about what matters.
6.6 Implementing Search and Filtering
With the core functionality working (add, list, complete, delete, and persistence), we can now add the search feature we deferred earlier. This is a good example of the iterative, incremental approach: we built a solid foundation first, verified it works, and are now extending it.
The Search Prompt
Add a search subcommand that takes a keyword and returns all tasks whose
descriptions contain that keyword. The search should be case-insensitive.
This is a direct request --- short, clear, and focused on a single feature. Direct requests work well when the AI already has context about the existing code (from earlier in the conversation) and the request is straightforward.
The AI produces:
def search_tasks(keyword: str) -> list[Task]:
tasks = load_tasks()
keyword_lower = keyword.lower()
return [t for t in tasks if keyword_lower in t.description.lower()]
This is a straightforward list comprehension (Section 5.12) that converts both the keyword and each description to lowercase before checking for containment. The in operator for strings checks whether the keyword appears anywhere within the description. The AI also adds a new search subparser to build_parser() and integrates the search handling into main().
Let us test it:
$ python task_manager.py add "Buy groceries for the week"
Added task 1: Buy groceries for the week
$ python task_manager.py add "Write grocery list"
Added task 2: Write grocery list
$ python task_manager.py add "Read Python chapter"
Added task 3: Read Python chapter
$ python task_manager.py search "grocery"
Search results for "grocery"
--------------------------------------------------
1. [ ] Buy groceries for the week
2. [ ] Write grocery list
Total: 2 task(s)
The search correctly finds both tasks containing "grocery" (even though task 1 has "groceries" with an extra letter), because the in operator checks for substring containment, not exact match. It also correctly ignores task 3 since "Read Python chapter" does not contain "grocery".
Adding Status Filtering
While we are extending the list functionality, let us add the ability to filter by completion status:
Also add a --status flag to the list command so I can filter by 'pending'
or 'completed'. For example:
python task_manager.py list --status pending
Notice the example-driven element in this prompt. By showing the exact command we want to type, we give the AI a concrete target to implement toward.
The AI adds an optional --status argument to the list subparser:
list_parser.add_argument(
"--status",
choices=["pending", "completed"],
default=None,
help="Filter tasks by status",
)
The choices parameter means argparse will automatically reject any value other than "pending" or "completed", producing a helpful error message. The default=None means that if no --status flag is provided, all tasks are shown.
The AI also updates list_tasks() to accept and apply the filter:
def list_tasks(status_filter: Optional[str] = None) -> list[Task]:
tasks = load_tasks()
if status_filter == "pending":
return [t for t in tasks if not t.completed]
if status_filter == "completed":
return [t for t in tasks if t.completed]
return tasks
Notice the three-way logic: "pending" shows incomplete tasks, "completed" shows finished tasks, and None (no filter) shows everything. Using a string rather than a boolean allows this tri-state behavior naturally.
Real-World Application: Search and filtering are among the most commonly requested features in any data-oriented application. The pattern you see here --- load all data, filter in memory, return results --- works well for small datasets (hundreds or a few thousand items). For larger applications, you would want to use a database with proper indexing, which is covered in Chapter 18.
6.7 Adding Priority and Due Dates
Now that the core application is solid, let us extend it with priority levels and due dates. This demonstrates how vibe coding enables iterative feature development --- you build a working foundation, then add capabilities layer by layer.
The Enhancement Prompt
I want to add priority levels (low, medium, high) and optional due dates
to tasks. Update the Task dataclass and the add command. The priority
should default to 'medium' and the due date should be optional. Due dates
should be in YYYY-MM-DD format.
What the AI Produces
The AI extends the Task dataclass with two new fields:
@dataclass
class Task:
id: int
description: str
completed: bool = False
priority: str = "medium"
due_date: Optional[str] = None
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
completed_at: Optional[str] = None
It also adds --priority and --due arguments to the add subparser:
add_parser.add_argument(
"--priority", "-p",
type=str,
choices=["low", "medium", "high"],
default="medium",
help="Priority level (default: medium)",
)
add_parser.add_argument(
"--due", "-d",
type=str,
default=None,
help="Due date in YYYY-MM-DD format",
)
Note the short flag aliases (-p for --priority, -d for --due) that the AI added. These are a nice touch --- they make the CLI more convenient to use. Now you can run:
python task_manager.py add "Finish report" --priority high --due 2025-12-15
python task_manager.py add "Buy milk" -p low
The AI also updates the display to show priority and due date information, and updates add_task() to accept the new parameters.
Advanced: The enhanced version (see
code/example-03-enhanced-version.py) goes further by adding date validation usingdate.fromisoformat(), overdue detection with color-coded output, sorting options (by priority, due date, or creation date), categories, an edit command, statistics, and CSV export. This illustrates how a simple vibe coding project can grow incrementally into a feature-rich application. Each enhancement was added through a single prompt or short sequence of prompts.
A Note on Backward Compatibility
Adding fields to the Task dataclass raises an important question: what happens to tasks saved before the new fields existed? The JSON file contains tasks without priority and due_date fields. Will they still load?
Yes, they will. Because we use Task(**item) to reconstruct tasks from dictionaries, and because the new fields have default values, old task data loads correctly. The missing priority field defaults to "medium", and due_date defaults to None. This is a benefit of designing with sensible defaults from the start.
Common Pitfall: If you add a new required field (one without a default value) to a dataclass, existing serialized data will fail to load because the field will be missing from the stored dictionaries. Python will raise a
TypeError: __init__() missing 1 required positional argument. Always add new fields with defaults when evolving a data model that has existing persisted data. This is a form of backward compatibility, and it matters in any application where users have existing data files.
6.8 Error Handling and Edge Cases
Production-quality software handles errors gracefully rather than crashing with a Python traceback. Up to this point, our application works well with valid inputs, but it behaves badly when things go wrong: entering a non-existent task ID produces a confusing output, an empty description creates a useless task, and a corrupted JSON file crashes the program.
Let us ask the AI to harden our application.
The Error Handling Prompt
Let's add proper error handling. What could go wrong with this
application? I want it to handle:
- Invalid task IDs (non-existent, negative)
- Empty descriptions
- Corrupted JSON file
- File permission errors
Show me the updated code with try/except blocks where needed.
This prompt lists four specific failure scenarios. This specificity is important --- a vague "add error handling" prompt would produce mediocre results because the AI would have to guess which errors you care about.
What the AI Produces
The AI makes several improvements across the codebase:
1. JSON parsing protection in load_tasks():
def load_tasks() -> list[Task]:
path = Path(DEFAULT_DATA_FILE)
if not path.exists():
return []
try:
with open(path, "r", encoding="utf-8") as fh:
raw_data = json.load(fh)
return [Task(**item) for item in raw_data]
except json.JSONDecodeError:
print(f"Error: Could not parse {path}. The file may be corrupted.")
return []
except (TypeError, KeyError) as exc:
print(f"Error: Invalid task data in {path}: {exc}")
return []
This catches two categories of errors: json.JSONDecodeError when the file is not valid JSON at all (e.g., it was accidentally overwritten with random text), and TypeError/KeyError when the JSON is valid but does not match the Task dataclass structure (e.g., a field is missing or has the wrong type).
2. Description validation in main():
if args.command == "add":
description = args.description.strip()
if not description:
print("Error: Task description cannot be empty.")
return 1
The .strip() call removes leading and trailing whitespace, so " " is treated as empty. The function returns 1 to indicate an error through the exit code.
3. Missing task ID handling:
if args.command == "complete":
task = complete_task(args.id)
if task is None:
print(f"Error: No task found with ID {args.id}.")
return 1
print(f"Completed task {task.id}: {task.description}")
return 0
When complete_task() cannot find a task with the given ID, it returns None instead of crashing. The main() function checks for None and prints a clear error message.
Notice that the AI uses specific exception types (json.JSONDecodeError, OSError, TypeError) rather than bare except clauses. This is a best practice from Section 5.9 --- catching broad exceptions can mask unrelated bugs. If there is a NameError (a typo in the code) or a KeyboardInterrupt (the user pressing Ctrl+C), those should propagate normally, not be silently swallowed.
Best Practice: When asking the AI to add error handling, list the specific failure scenarios you want covered. A vague "add error handling" prompt produces mediocre results. Listing specific scenarios ("corrupted JSON file", "non-existent task ID") produces targeted, useful error handling.
Testing the Error Handling
Let us verify our error handling works by deliberately triggering each edge case:
# Try to complete a task that does not exist
$ python task_manager.py complete 999
Error: No task found with ID 999.
# Try to add an empty task
$ python task_manager.py add ""
Error: Task description cannot be empty.
# Try to add a whitespace-only task
$ python task_manager.py add " "
Error: Task description cannot be empty.
# Corrupt the JSON file and try to load
$ echo "not json" > tasks.json
$ python task_manager.py list
Error: Could not parse tasks.json. The file may be corrupted.
No tasks found.
Each error produces a clear, human-readable message instead of a Python traceback. The exit codes are also correct: 0 for success, 1 for errors. This matters because scripts and shell pipelines can check exit codes to determine whether a command succeeded.
Intuition: Error handling is one of those areas where the AI needs explicit guidance. Left to its own devices, the AI often produces "happy path" code that works perfectly when inputs are valid but crashes on edge cases. Part of your role as a vibe coder is to think adversarially: what could go wrong? What happens if the user types something unexpected? What if the file system is unavailable? Then ask the AI to handle those cases. This adversarial thinking is a skill that improves with practice.
6.9 Testing Your Application
A working application is good, but a tested application is better. Testing is especially important with AI-generated code because the AI may have introduced subtle bugs that you did not catch during manual testing. Manual testing tends to follow the happy path --- you test the things you expect to work. Automated tests can systematically cover edge cases and regressions.
The Testing Prompt
Write unit tests for the task manager using pytest. Test at least these
scenarios:
- Adding a task creates it with correct fields
- Listing tasks returns the right count
- Completing a task sets completed=True and completed_at
- Deleting a task removes it from the list
- Searching is case-insensitive
- Loading from a non-existent file returns empty list
Use a temporary directory for the test data file.
Again, we list specific test scenarios rather than asking for "some tests." This ensures the AI writes meaningful tests that verify the behaviors we care about.
What the AI Produces
The AI generates a comprehensive test file using pytest:
import pytest
from pathlib import Path
from task_manager import (
Task, add_task, list_tasks, complete_task,
delete_task, search_tasks, load_tasks,
)
@pytest.fixture
def data_path(tmp_path: Path) -> Path:
"""Provide a temporary file path for test data."""
return tmp_path / "test_tasks.json"
def test_add_task_creates_with_correct_fields(data_path: Path) -> None:
task = add_task("Buy groceries", data_path=data_path)
assert task.id == 1
assert task.description == "Buy groceries"
assert task.completed is False
assert task.created_at is not None
assert task.completed_at is None
def test_list_tasks_returns_correct_count(data_path: Path) -> None:
add_task("Task 1", data_path=data_path)
add_task("Task 2", data_path=data_path)
add_task("Task 3", data_path=data_path)
tasks = list_tasks(data_path=data_path)
assert len(tasks) == 3
def test_complete_task_sets_fields(data_path: Path) -> None:
add_task("Test task", data_path=data_path)
task = complete_task(1, data_path=data_path)
assert task is not None
assert task.completed is True
assert task.completed_at is not None
def test_delete_task_removes_it(data_path: Path) -> None:
add_task("To delete", data_path=data_path)
assert delete_task(1, data_path=data_path) is True
tasks = list_tasks(data_path=data_path)
assert len(tasks) == 0
def test_search_is_case_insensitive(data_path: Path) -> None:
add_task("Buy GROCERIES", data_path=data_path)
results = search_tasks("groceries", data_path=data_path)
assert len(results) == 1
def test_load_from_nonexistent_file(tmp_path: Path) -> None:
path = tmp_path / "nonexistent.json"
tasks = load_tasks(data_path=path)
assert tasks == []
Let us understand what is happening here. The @pytest.fixture decorator (from pytest's test infrastructure) creates a reusable setup function. Each test function that takes data_path as a parameter automatically receives a fresh temporary file path. The tmp_path fixture is built into pytest and provides a unique temporary directory for each test, automatically cleaned up afterward.
This means each test runs in complete isolation. Test A cannot accidentally affect Test B because they use different temporary directories. This isolation is essential for reliable tests.
Note
For these tests to work, the task manager functions need to accept an optional data_path parameter that overrides the default file location. This is a design pattern called dependency injection --- instead of hardcoding the file path inside each function, we accept it as a parameter, which makes the code testable. The AI incorporated this pattern because we asked for tests that use a temporary directory. This is a case where the desire to test actually improved the code's design.
Adding Edge Case Tests
After the main tests pass, we ask for more:
The tests are passing but they don't test what happens when I pass
invalid arguments to main(). Can you add tests for:
- Calling main() with no arguments
- Trying to complete with a non-existent ID
- Adding a task with an empty description
The AI adds additional tests covering these error scenarios, using capsys (pytest's output capture fixture) to verify that error messages are printed correctly, and asserting that the return code is 1 for error cases.
Running the Tests
$ python -m pytest test_task_manager.py -v
============================== test session starts ==============================
test_task_manager.py::test_add_task_creates_with_correct_fields PASSED
test_task_manager.py::test_list_tasks_returns_correct_count PASSED
test_task_manager.py::test_complete_task_sets_fields PASSED
test_task_manager.py::test_delete_task_removes_it PASSED
test_task_manager.py::test_search_is_case_insensitive PASSED
test_task_manager.py::test_load_from_nonexistent_file PASSED
test_task_manager.py::test_complete_nonexistent_task PASSED
test_task_manager.py::test_empty_description PASSED
============================== 8 passed ==============================
All eight tests pass. Our application is now both functional and verified.
Real-World Application: Testing is covered in depth in Chapter 21, but the key lesson here is that testing AI-generated code is more important than testing hand-written code. When you write code yourself, you understand every line --- you know the assumptions, the edge cases, and the shortcuts. With AI-generated code, there may be assumptions or edge cases you did not anticipate. Tests catch those issues. Think of tests as a safety net: they catch you when you fall, and with AI-generated code, you are more likely to fall in unexpected places.
6.10 Reflecting on the Vibe Coding Process
Let us step back and look at what just happened. In a single session, we built a complete, working CLI application with:
- A clean data model using Python dataclasses
- File-based persistence with JSON and atomic write protection
- A full CLI interface with subcommands and help text
- Search and filtering capabilities
- Priority levels and optional due dates
- Comprehensive error handling for edge cases
- A test suite with eight passing tests
This is not a toy example. It is a genuinely useful tool that you could use daily to manage your tasks from the terminal. And we built it through conversation, not by memorizing syntax or poring over documentation.
The Prompt Sequence
Over the course of the session, we sent approximately 16 prompts to the AI. Here is the complete sequence, classified by technique:
| # | Purpose | Section | Technique Used |
|---|---|---|---|
| 1 | Project planning | 6.2 | Direct request |
| 2 | Scope narrowing | 6.2 | Iterative refinement |
| 3 | Data model | 6.3 | Specification |
| 4 | Add completed_at field | 6.3 | Iterative refinement |
| 5 | CLI interface | 6.4 | Specification |
| 6 | Improve display formatting | 6.4 | Example-driven |
| 7 | JSON persistence | 6.5 | Specification |
| 8 | Atomic write safety | 6.5 | Constraint-based |
| 9 | Search command | 6.6 | Direct request |
| 10 | Status filtering | 6.6 | Example-driven |
| 11 | Priority and due dates | 6.7 | Specification |
| 12 | Error handling | 6.8 | Specification |
| 13 | Better error messages | 6.8 | Error correction |
| 14 | Unit tests | 6.9 | Specification |
| 15 | Edge case tests | 6.9 | Extension |
| 16 | Reflection and review | 6.10 | Role-based |
Notice the variety of techniques used. No single approach dominates. Effective vibe coding means having multiple techniques in your toolkit and choosing the right one for each situation. These techniques are formalized in Chapters 8 through 12.
Lessons Learned
Several patterns emerged during this session that apply to all vibe coding projects:
1. Planning first pays off. The initial planning prompt (Prompt 1) gave the AI context that improved every subsequent interaction. The AI's code was more consistent because it understood the overall architecture from the start. Without that context, the CLI might have used one naming convention while the storage layer used another.
2. Iteration is the norm, not the exception. We refined the data model (Prompt 4), improved the display (Prompt 6), added safety measures (Prompt 8), and fixed error messages (Prompt 13). None of these were failures --- they were natural steps in the development process. A vibe coding session is a conversation, and conversations evolve.
3. Specific prompts produce better code. Compare Prompt 3 ("Create a Python dataclass called Task with fields for id (int), description (str)...") with a hypothetical vague alternative ("Create a task class"). The specific prompt got the exact code we wanted on the first try. The vague prompt would have required multiple rounds of correction.
4. Example-driven prompts work well for formatting. When we wanted specific output formatting (Prompt 6), showing the AI an example of the desired output was the most effective technique. Words like "formatted nicely" or "easy to read" are subjective; a concrete example is unambiguous.
5. You must think about edge cases. The AI did not spontaneously add atomic writes (Prompt 8) or handle corrupted files (Prompt 12). We had to identify those needs and ask explicitly. The AI excels at implementing solutions to problems you define; it is less reliable at anticipating problems you have not mentioned.
6. Testing reveals issues you did not see. Writing tests forced us to design the code for testability (dependency injection of the file path) and verified that our error handling actually works. The act of specifying test scenarios also helped us think more rigorously about the application's behavior.
The Final Reflection Prompt
We close the session by asking the AI to review its own work:
Looking back at the code we built, what would you change to make it more
production-ready? What are the biggest limitations and what would be the
next steps to improve it?
This is a role-based prompt --- we are asking the AI to act as a code reviewer rather than a code generator. The AI suggests:
- Database storage: Replace JSON with SQLite for better performance with large task lists. JSON requires loading the entire file into memory for every operation, which does not scale.
- Undo/redo: Add the ability to undo the last action, useful for accidentally deleting or completing the wrong task.
- Recurring tasks: Support tasks that repeat on a schedule (daily, weekly, monthly).
- Rich terminal UI: Use a library like
richortextualfor color-coded, formatted terminal output instead of plain text. - Configuration file: Support user preferences for colors, default priority, data file location, and date format.
- Packaging: Make the tool installable via
pip installso it can be run from anywhere as a system command.
These are all excellent next steps, and each one could be implemented through additional vibe coding sessions. This is the beauty of the approach: you build incrementally, and each session adds capability.
Intuition: Using the AI as a code reviewer after building is a valuable practice. The AI can identify blind spots and suggest improvements you might not have considered. It serves as a second pair of eyes, even though it is not a human pair. Many professional developers use this technique at the end of every coding session.
What Made This Session Successful
Looking back, seven factors contributed to the success of this session:
- We started with a clear, achievable goal. A CLI task manager is well-defined and completable in one session. We did not try to build a web app or a mobile app or a distributed system.
- We planned before coding. The opening prompts established context and architecture. We knew what functions we needed before writing any of them.
- We iterated incrementally. Each prompt added one feature or improvement. We never tried to generate the entire application in a single prompt.
- We were specific in our requests. We specified types, field names, formats, and behaviors. The more specific our prompts, the better the AI's output.
- We tested the code. Both manually (running commands in the terminal) and with automated tests (pytest).
- We thought about edge cases. Error handling and data safety were explicitly requested. We did not wait for problems to occur --- we anticipated them.
- We reflected at the end. Asking the AI for improvements identified areas for future work and closed the learning loop.
This workflow --- plan, generate, evaluate, iterate, test, reflect --- is the core loop of vibe coding. In the chapters ahead, you will apply this same workflow to increasingly complex projects: web applications (Chapters 16-19), APIs (Chapter 17), database-backed systems (Chapter 18), and more. The scale changes, but the workflow remains the same.
Connecting to What Is Next
In Chapter 7, you will learn to read AI-generated code with a critical eye. The code we built in this chapter is your first specimen for analysis. You will learn to evaluate code structure, identify potential issues, assess quality, and build a checklist for reviewing AI output. The skills from this hands-on session will ground that analysis in practical experience --- you have seen how code is generated, and now you will learn to systematically evaluate it.
The prompting techniques we used here --- planning prompts, specification prompts, example-driven prompts, iterative refinement --- are formalized and expanded in Part II (Chapters 8--14). This chapter gave you intuitive experience with these techniques; Part II will give you the theory, taxonomy, and advanced variations.
And the CLI application pattern you built here --- data model, persistence layer, CLI interface --- will appear again in Chapter 15, where we build more sophisticated command-line tools using libraries like click for declarative CLI design and rich for beautiful terminal output.
Your first vibe coding session is complete. You have built something real, and you have experienced the workflow firsthand. Every chapter from here builds on this foundation.
Summary
This chapter walked through a complete vibe coding session from start to finish. You learned to:
- Begin with a planning prompt that gives the AI full context about the project
- Narrow scope by deferring features to later iterations
- Generate a data model using Python dataclasses with type hints and docstrings
- Build a CLI interface using
argparsesubcommands with help text - Add JSON file persistence with atomic write protection against data corruption
- Implement search and filtering using list comprehensions and optional parameters
- Extend the application incrementally with priority levels and due dates
- Add comprehensive error handling by listing specific failure scenarios
- Write unit tests using
pytestwith temporary file fixtures for isolation - Use the AI as a code reviewer to identify improvements
- Reflect on the process to extract reusable lessons
The complete code for the task manager is available in code/example-01-task-manager.py. The enhanced version with priorities, due dates, categories, sorting, statistics, and CSV export is in code/example-03-enhanced-version.py. The full catalog of prompts used during the session, with metadata about each prompt's technique and intent, is documented in code/example-02-prompts-used.py.