This is the last Quick Build in the series. We have built a CLI app, a REST API, a landing page, and a Chrome extension. Now we build something that runs 24/7 and interacts with real users: a Discord bot.
Discord bots are a great test for Claude Code because they involve:
- An external API (Discord API)
- Event-driven architecture
- Slash command registration
- Permission handling
- Error recovery
- Deployment for uptime
Total time: 71 minutes. Most of that was fighting with slash command registration.
What We’re Building
A Discord bot with:
- Utility commands: /ping (latency), /userinfo (user details), /serverinfo (server stats)
- Fun commands: /poll (create polls with reactions), /remind (set reminders), /quote (random quote from API)
- Moderation commands: /kick, /ban, /timeout, /clear (bulk delete messages)
- Welcome system: custom embed message when a user joins
- Error handling: log errors to a specific channel, graceful shutdown
Tech stack: Node.js, TypeScript, discord.js v14, deployed on Railway
Prerequisites
- Claude Code installed and configured
- Node.js 18+ installed
- A Discord account
- A Discord server where you have admin permissions
Setting Up the Discord Application
Before writing any code, you need a bot token from Discord:
- Go to Discord Developer Portal
- Click “New Application” and name it “FlowBot”
- Go to the “Bot” tab and click “Add Bot”
- Copy the bot token (you will need this later)
- Under “Privileged Gateway Intents”, enable: Server Members Intent, Message Content Intent
- Go to “OAuth2” > “URL Generator”
- Select scopes:
bot,applications.commands - Select permissions: Send Messages, Manage Messages, Kick Members, Ban Members, Moderate Members, Embed Links, Add Reactions, Read Message History
- Copy the generated URL and open it to invite the bot to your server
Save the bot token. You will need it for the .env file.
The Build Session
Total time: 71 minutes. 7 prompts.
Prompt 1: Project Setup and Basic Commands (Minute 0)
Create a Discord bot using discord.js v14 with TypeScript. Set up the project
with:
- tsconfig.json, package.json with scripts for build and start
- .env file for BOT_TOKEN and CLIENT_ID
- src/index.ts as the entry point
- src/commands/ directory with each command as a separate file
Slash commands:
1. /ping — shows bot latency and API latency
2. /userinfo [user] — shows user avatar, join date, roles, account age
3. /serverinfo — shows server name, member count, channel count, creation date
Use embeds for all responses. Color scheme: indigo (#5865F2, Discord's brand color).
What Claude generated:
A well-organized project structure:
flowbot/
src/
index.ts
commands/
ping.ts
userinfo.ts
serverinfo.ts
deploy-commands.ts
.env.example
.gitignore
package.json
tsconfig.json
The entry point:
import { Client, GatewayIntentBits, Collection, Events } from "discord.js";
import { config } from "dotenv";
import { pingCommand } from "./commands/ping";
import { userinfoCommand } from "./commands/userinfo";
import { serverinfoCommand } from "./commands/serverinfo";
config();
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
const commands = new Collection<string, any>();
commands.set(pingCommand.data.name, pingCommand);
commands.set(userinfoCommand.data.name, userinfoCommand);
commands.set(serverinfoCommand.data.name, serverinfoCommand);
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const command = commands.get(interaction.commandName);
if (!command) return;
try {
await command.execute(interaction);
} catch (error) {
console.error(`Error executing ${interaction.commandName}:`, error);
const reply = {
content: "Something went wrong while running this command.",
ephemeral: true,
};
if (interaction.replied || interaction.deferred) {
await interaction.followUp(reply);
} else {
await interaction.reply(reply);
}
}
});
client.once(Events.ClientReady, (c) => {
console.log(`Bot is ready! Logged in as ${c.user.tag}`);
});
client.login(process.env.BOT_TOKEN);
The ping command:
import { SlashCommandBuilder, ChatInputCommandInteraction, EmbedBuilder } from "discord.js";
export const pingCommand = {
data: new SlashCommandBuilder()
.setName("ping")
.setDescription("Check bot and API latency"),
async execute(interaction: ChatInputCommandInteraction) {
const sent = await interaction.reply({
content: "Pinging...",
fetchReply: true,
});
const roundtrip = sent.createdTimestamp - interaction.createdTimestamp;
const wsLatency = interaction.client.ws.ping;
const embed = new EmbedBuilder()
.setColor(0x5865f2)
.setTitle("Pong!")
.addFields(
{ name: "Roundtrip", value: `${roundtrip}ms`, inline: true },
{ name: "WebSocket", value: `${wsLatency}ms`, inline: true }
)
.setTimestamp();
await interaction.editReply({ content: null, embeds: [embed] });
},
};
Claude also generated a deploy-commands.ts script for registering slash commands with Discord:
import { REST, Routes } from "discord.js";
import { config } from "dotenv";
import { pingCommand } from "./commands/ping";
import { userinfoCommand } from "./commands/userinfo";
import { serverinfoCommand } from "./commands/serverinfo";
config();
const commands = [
pingCommand.data.toJSON(),
userinfoCommand.data.toJSON(),
serverinfoCommand.data.toJSON(),
];
const rest = new REST().setToken(process.env.BOT_TOKEN!);
async function deploy() {
try {
console.log(`Registering ${commands.length} commands...`);
await rest.put(
Routes.applicationCommands(process.env.CLIENT_ID!),
{ body: commands }
);
console.log("Commands registered successfully!");
} catch (error) {
console.error("Failed to register commands:", error);
}
}
deploy();
My review: The structure was clean. Each command in its own file, proper TypeScript types, error handling in the interaction handler. The slash command builder API was used correctly.
I set up the project:
npm install
echo "BOT_TOKEN=your_token_here" > .env
echo "CLIENT_ID=your_client_id_here" >> .env
npm run build
Time spent: 9 minutes
Prompt 2: Register and Test Commands (Minute 9)
The build succeeded. Now I needed to register the slash commands with Discord and test them.
npx tsx src/deploy-commands.ts
npm start
The commands registered, the bot came online, and /ping worked in my test server. The embed showed roundtrip and WebSocket latency.
/userinfo worked but had a minor issue — it crashed when no user was mentioned because the optional user parameter was not handled. I asked Claude to fix it:
/userinfo crashes when no user is mentioned. Make the user parameter optional
and default to the command invoker if no user is specified.
Claude fixed it:
const targetUser = interaction.options.getUser("user") || interaction.user;
const member = interaction.guild?.members.cache.get(targetUser.id)
|| await interaction.guild?.members.fetch(targetUser.id);
Time spent: 5 minutes
Prompt 3: Fun Commands (Minute 14)
Add these slash commands:
1. /poll question choice1 choice2 [choice3] [choice4] [choice5]
— Creates an embed with the question and choices
— Adds number emoji reactions (1-5) for voting
— Show results after 60 seconds (edit the embed with vote counts)
2. /remind time message
— Time format: "10m" (minutes), "1h" (hours), "30s" (seconds)
— Sends a DM to the user after the time expires
— Confirms with an ephemeral reply
3. /quote
— Fetches a random quote from https://api.quotable.io/random
— Displays as an embed with the quote, author, and tags
What Claude generated:
The poll command was the most complex:
export const pollCommand = {
data: new SlashCommandBuilder()
.setName("poll")
.setDescription("Create a poll")
.addStringOption(opt => opt.setName("question").setDescription("The poll question").setRequired(true))
.addStringOption(opt => opt.setName("choice1").setDescription("Choice 1").setRequired(true))
.addStringOption(opt => opt.setName("choice2").setDescription("Choice 2").setRequired(true))
.addStringOption(opt => opt.setName("choice3").setDescription("Choice 3"))
.addStringOption(opt => opt.setName("choice4").setDescription("Choice 4"))
.addStringOption(opt => opt.setName("choice5").setDescription("Choice 5")),
async execute(interaction: ChatInputCommandInteraction) {
const question = interaction.options.getString("question", true);
const choices: string[] = [];
const emojis = ["1\u20E3", "2\u20E3", "3\u20E3", "4\u20E3", "5\u20E3"];
for (let i = 1; i <= 5; i++) {
const choice = interaction.options.getString(`choice${i}`);
if (choice) choices.push(choice);
}
const description = choices
.map((choice, i) => `${emojis[i]} ${choice}`)
.join("\n\n");
const embed = new EmbedBuilder()
.setColor(0x5865f2)
.setTitle(`Poll: ${question}`)
.setDescription(description)
.setFooter({ text: `Poll by ${interaction.user.displayName} — Results in 60 seconds` })
.setTimestamp();
const message = await interaction.reply({ embeds: [embed], fetchReply: true });
for (let i = 0; i < choices.length; i++) {
await message.react(emojis[i]);
}
// Collect results after 60 seconds
setTimeout(async () => {
try {
const fetched = await message.fetch();
const results = choices.map((choice, i) => {
const reaction = fetched.reactions.cache.get(emojis[i]);
const count = reaction ? reaction.count - 1 : 0; // -1 for bot's own reaction
return `${emojis[i]} ${choice} — **${count} vote${count !== 1 ? "s" : ""}**`;
});
const resultEmbed = new EmbedBuilder()
.setColor(0x5865f2)
.setTitle(`Poll Results: ${question}`)
.setDescription(results.join("\n\n"))
.setFooter({ text: `Poll by ${interaction.user.displayName} — Voting closed` })
.setTimestamp();
await message.edit({ embeds: [resultEmbed] });
} catch (error) {
console.error("Error fetching poll results:", error);
}
}, 60000);
},
};
The remind command:
export const remindCommand = {
data: new SlashCommandBuilder()
.setName("remind")
.setDescription("Set a reminder")
.addStringOption(opt => opt.setName("time").setDescription("Time (e.g., 10m, 1h, 30s)").setRequired(true))
.addStringOption(opt => opt.setName("message").setDescription("Reminder message").setRequired(true)),
async execute(interaction: ChatInputCommandInteraction) {
const timeStr = interaction.options.getString("time", true);
const message = interaction.options.getString("message", true);
const match = timeStr.match(/^(\d+)(s|m|h)$/);
if (!match) {
await interaction.reply({
content: "Invalid time format. Use: 30s, 10m, or 1h",
ephemeral: true,
});
return;
}
const value = parseInt(match[1]);
const unit = match[2];
const ms = unit === "s" ? value * 1000 : unit === "m" ? value * 60000 : value * 3600000;
if (ms > 86400000) {
await interaction.reply({
content: "Maximum reminder time is 24 hours.",
ephemeral: true,
});
return;
}
await interaction.reply({
content: `Reminder set! I will DM you in ${timeStr}.`,
ephemeral: true,
});
setTimeout(async () => {
try {
const embed = new EmbedBuilder()
.setColor(0x5865f2)
.setTitle("Reminder")
.setDescription(message)
.setTimestamp();
await interaction.user.send({ embeds: [embed] });
} catch (error) {
console.error("Could not send DM:", error);
}
}, ms);
},
};
Problem with /quote: Claude used https://api.quotable.io/random, which was a real API. But this API has been unreliable recently — sometimes returning 503 errors. Claude did not add retry logic or a fallback.
I asked for a fix:
The quote API sometimes returns 503. Add retry logic — try 3 times with
1 second delay between retries. If all retries fail, show a default quote
as fallback.
Claude added proper retry logic:
async function fetchQuote(retries = 3): Promise<{ content: string; author: string }> {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch("https://api.quotable.io/random");
if (response.ok) {
return await response.json();
}
} catch {
// Retry
}
if (i < retries - 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
return {
content: "The only way to do great work is to love what you do.",
author: "Steve Jobs",
};
}
Time spent: 12 minutes (including testing all three commands)
Prompt 4: Moderation Commands (Minute 26)
Add moderation slash commands:
1. /kick user [reason] — kick a member with optional reason
2. /ban user [reason] — ban a member with optional reason
3. /timeout user duration [reason] — timeout with duration (e.g., 10m, 1h)
4. /clear amount — delete the last N messages (1-100)
Requirements:
- Check if the bot has the required permissions before executing
- Check if the command user has the required permissions
- Cannot kick/ban/timeout the bot itself or server owner
- Log all moderation actions to a channel named "mod-log" if it exists
- Show confirmation embeds for all actions
What Claude generated:
The kick command with permission checks:
import {
SlashCommandBuilder,
ChatInputCommandInteraction,
EmbedBuilder,
PermissionFlagsBits,
GuildMember,
TextChannel,
} from "discord.js";
export const kickCommand = {
data: new SlashCommandBuilder()
.setName("kick")
.setDescription("Kick a member from the server")
.addUserOption(opt => opt.setName("user").setDescription("The user to kick").setRequired(true))
.addStringOption(opt => opt.setName("reason").setDescription("Reason for the kick"))
.setDefaultMemberPermissions(PermissionFlagsBits.KickMembers),
async execute(interaction: ChatInputCommandInteraction) {
const targetUser = interaction.options.getUser("user", true);
const reason = interaction.options.getString("reason") || "No reason provided";
const member = interaction.guild?.members.cache.get(targetUser.id);
if (!member) {
await interaction.reply({ content: "User not found in this server.", ephemeral: true });
return;
}
if (targetUser.id === interaction.client.user?.id) {
await interaction.reply({ content: "I cannot kick myself.", ephemeral: true });
return;
}
if (targetUser.id === interaction.guild?.ownerId) {
await interaction.reply({ content: "Cannot kick the server owner.", ephemeral: true });
return;
}
if (!member.kickable) {
await interaction.reply({
content: "I do not have permission to kick this user. They may have a higher role than me.",
ephemeral: true,
});
return;
}
try {
await member.kick(reason);
const embed = new EmbedBuilder()
.setColor(0xed4245)
.setTitle("Member Kicked")
.addFields(
{ name: "User", value: `${targetUser.tag}`, inline: true },
{ name: "Kicked by", value: `${interaction.user.tag}`, inline: true },
{ name: "Reason", value: reason }
)
.setTimestamp();
await interaction.reply({ embeds: [embed] });
await logModAction(interaction.guild!, embed);
} catch (error) {
await interaction.reply({ content: "Failed to kick the user.", ephemeral: true });
}
},
};
async function logModAction(guild: any, embed: EmbedBuilder) {
const modLog = guild.channels.cache.find(
(c: any) => c.name === "mod-log" && c.isTextBased()
) as TextChannel | undefined;
if (modLog) {
await modLog.send({ embeds: [embed] });
}
}
Claude used setDefaultMemberPermissions in the slash command builder to restrict visibility to users with the right permissions. This is the correct Manifest V3 way to handle permissions in discord.js v14.
The /clear command used bulkDelete:
export const clearCommand = {
data: new SlashCommandBuilder()
.setName("clear")
.setDescription("Delete messages in bulk")
.addIntegerOption(opt =>
opt.setName("amount").setDescription("Number of messages (1-100)").setRequired(true).setMinValue(1).setMaxValue(100)
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages),
async execute(interaction: ChatInputCommandInteraction) {
const amount = interaction.options.getInteger("amount", true);
if (!interaction.channel || !("bulkDelete" in interaction.channel)) {
await interaction.reply({ content: "Cannot delete messages in this channel.", ephemeral: true });
return;
}
try {
const deleted = await interaction.channel.bulkDelete(amount, true);
await interaction.reply({
content: `Deleted ${deleted.size} messages.`,
ephemeral: true,
});
} catch (error) {
await interaction.reply({
content: "Failed to delete messages. Messages older than 14 days cannot be bulk deleted.",
ephemeral: true,
});
}
},
};
Issue: Claude handled the 14-day bulkDelete limitation well — Discord does not allow bulk deletion of messages older than 14 days. The error message explained this clearly.
Time spent: 10 minutes
Prompt 5: Welcome System (Minute 36)
Add a welcome system:
- When a new member joins, send a welcome embed in a channel named "welcome"
- The embed should include: user avatar, username, account creation date,
server member count, and a welcome message
- Use a green color (#57f287) for the embed
- If no "welcome" channel exists, skip silently
What Claude generated:
An event listener in index.ts:
client.on(Events.GuildMemberAdd, async (member) => {
const welcomeChannel = member.guild.channels.cache.find(
(c) => c.name === "welcome" && c.isTextBased()
) as TextChannel | undefined;
if (!welcomeChannel) return;
const accountAge = Math.floor(
(Date.now() - member.user.createdTimestamp) / 86400000
);
const embed = new EmbedBuilder()
.setColor(0x57f287)
.setTitle("Welcome to the server!")
.setDescription(
`Hey ${member}, welcome to **${member.guild.name}**! You are member #${member.guild.memberCount}.`
)
.setThumbnail(member.user.displayAvatarURL({ size: 256 }))
.addFields(
{ name: "Account Created", value: `${accountAge} days ago`, inline: true },
{ name: "Member Count", value: `${member.guild.memberCount}`, inline: true }
)
.setFooter({ text: `ID: ${member.id}` })
.setTimestamp();
try {
await welcomeChannel.send({ embeds: [embed] });
} catch (error) {
console.error("Failed to send welcome message:", error);
}
});
This worked on the first try. When I invited an alt account to the test server, the welcome embed appeared with the avatar, account age, and member count.
Time spent: 4 minutes
Prompt 6: Error Logging and Graceful Shutdown (Minute 40)
Add:
1. Error logging — send unhandled errors to a channel named "bot-logs"
as an embed with the error message, stack trace (truncated to 1000 chars),
and timestamp
2. Graceful shutdown — handle SIGINT and SIGTERM. Log "Bot shutting down..."
to bot-logs, destroy the client, and exit
3. Catch unhandled promise rejections globally
What Claude generated:
async function logError(client: Client, error: Error, context: string) {
console.error(`[${context}]`, error);
for (const guild of client.guilds.cache.values()) {
const logChannel = guild.channels.cache.find(
(c) => c.name === "bot-logs" && c.isTextBased()
) as TextChannel | undefined;
if (logChannel) {
const embed = new EmbedBuilder()
.setColor(0xed4245)
.setTitle(`Error: ${context}`)
.setDescription(`\`\`\`\n${error.message}\n\`\`\``)
.addFields({
name: "Stack Trace",
value: `\`\`\`\n${(error.stack || "No stack trace").substring(0, 1000)}\n\`\`\``,
})
.setTimestamp();
try {
await logChannel.send({ embeds: [embed] });
} catch {
// If we cannot log to Discord, just console.error
}
}
}
}
// Global error handlers
process.on("unhandledRejection", (error: Error) => {
logError(client, error, "Unhandled Rejection");
});
process.on("uncaughtException", (error: Error) => {
logError(client, error, "Uncaught Exception");
});
// Graceful shutdown
async function shutdown(signal: string) {
console.log(`Received ${signal}. Shutting down...`);
for (const guild of client.guilds.cache.values()) {
const logChannel = guild.channels.cache.find(
(c) => c.name === "bot-logs" && c.isTextBased()
) as TextChannel | undefined;
if (logChannel) {
try {
await logChannel.send({
embeds: [
new EmbedBuilder()
.setColor(0xfee75c)
.setTitle("Bot Shutting Down")
.setDescription(`Signal: ${signal}`)
.setTimestamp(),
],
});
} catch {
// Best effort
}
}
}
client.destroy();
process.exit(0);
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
Time spent: 5 minutes
Prompt 7: Dockerfile and Deployment (Minute 45)
Create a Dockerfile and docker-compose.yml for the bot. Use Node 20 alpine
image. Multi-stage build. The bot should restart automatically if it crashes.
Include a .dockerignore file.
What Claude generated:
Dockerfile:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
docker-compose.yml:
version: "3.8"
services:
bot:
build: .
restart: unless-stopped
env_file:
- .env
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
.dockerignore:
node_modules
dist
.env
*.md
.git
Clean and simple. The restart: unless-stopped ensures the bot comes back if it crashes. The logging configuration prevents log files from growing too large.
Time spent: 4 minutes
The remaining time (26 minutes) was spent:
- Registering all new commands with Discord (re-running
deploy-commands.ts) - Testing each command in the Discord server
- Fixing a bug where the
/timeoutcommand duration parsing did not handle the “d” (days) unit - Updating the
deploy-commands.tsto include all new commands (Claude had not updated it when adding new commands)
That last issue was the most frustrating. Every time Claude added new commands, it updated index.ts to import and register them, but forgot to update deploy-commands.ts. I had to ask three separate times.
Total time: 71 minutes.
What Went Right
- discord.js v14 knowledge was solid. Claude used the correct API for slash commands, embeds, permissions, events, and intents. No deprecated v13 patterns.
- Permission handling was comprehensive. Bot permissions, user permissions, hierarchy checks (cannot kick server owner), and the
setDefaultMemberPermissionsapproach were all correct. - The moderation logging pattern was good. Sending logs to a named channel with a fallback (skip if channel does not exist) is how production bots handle this.
- Error handling covered edge cases. The interaction handler checked for
repliedanddeferredstates before responding, which prevents the common “Interaction already replied” error.
What Went Wrong
1. Forgot to Update deploy-commands.ts (Three Times)
Every time Claude added new commands, it imported them in index.ts but forgot to add them to deploy-commands.ts. Slash commands must be registered with Discord’s API before they appear in servers. This caused confusion: the bot was running, but new commands were invisible.
Lesson: Tell Claude upfront: “Every time you add a new command, update both index.ts AND deploy-commands.ts.” Or better: use a dynamic command loader that scans the commands directory.
2. In-Memory Reminders
The /remind command uses setTimeout, which means all reminders are lost when the bot restarts. For a production bot, you would store reminders in a database and check them on startup.
Lesson: AI takes the simplest approach. If persistence matters, say so in the prompt: “Reminders must survive bot restarts. Use SQLite or Redis.”
3. Quote API Unreliability
The free quotable.io API returned 503 errors about 20% of the time during testing. Claude added retry logic after I asked, but the fallback quote was always the same Steve Jobs quote.
Lesson: For production bots, either use a paid API, cache quotes locally, or maintain a local database of quotes.
4. No Dynamic Command Loading
Each new command required manual imports in two files. This does not scale. A dynamic loader that reads the commands/ directory would be better. Claude did not suggest this architecture — it used explicit imports.
Lesson: For projects that will grow, ask for scalable patterns upfront. “Load all commands dynamically from the commands/ directory.”
The Final Result
A feature-rich Discord bot with:
- 9 slash commands (ping, userinfo, serverinfo, poll, remind, quote, kick, ban, timeout, clear)
- Permission checks on all moderation commands
- Welcome embeds for new members
- Error logging to a dedicated channel
- Graceful shutdown handling
- Docker deployment ready
To run it yourself:
# Install dependencies
npm install
# Set up environment
cp .env.example .env
# Edit .env with your BOT_TOKEN and CLIENT_ID
# Register commands
npx tsx src/deploy-commands.ts
# Run locally
npm run dev
# Or with Docker
docker compose up --build
Time and Cost
- Total time: 71 minutes
- Claude prompts: 7 (plus 4 fixes)
- Estimated token usage: ~35,000 tokens
- Hosting cost: $0 (Railway free tier for hobby projects)
- Dependencies: discord.js, dotenv, typescript
Source Code
Full source code: kemalcodes/vibe-coding-projects (branch: discord-bot-flowbot)
Related Articles
- Build a Chrome Extension with Claude — Previous article
- Build a CLI Todo App with Claude — First article in this series
- What is Vibe Coding? — The concept behind this series
- Claude Code Mastery — Advanced Claude Code patterns
What’s Next?
That wraps up Part 1: Quick Builds. Five projects in five articles, from a simple CLI app to a 24/7 Discord bot.
Key takeaways from Part 1:
Simple projects (CLI, landing page) are nearly perfect out of the box. Claude generates working code in one or two prompts. These are 30-40 minute projects.
API-based projects need more iteration. The REST API and Discord bot both required permission fixes, missing imports, and edge case handling. Budget 60-90 minutes.
Claude’s biggest weakness is consistency across files. When one change affects multiple files (like adding a new Discord command), Claude often updates one file and forgets the others.
The “What Went Wrong” pattern is real. Every project had 2-4 issues. None were showstoppers. All were fixable with one follow-up prompt.
Total time for all 5 projects: about 4.5 hours. Without Claude, these would have been 20-30 hours of work.
In Part 2, we tackle bigger projects: a full-stack blog, a weather dashboard, and a Markdown editor. Same format: real prompts, real mistakes, real results. Stay tuned.