Setup &
First Build
Today the logic gets real. No Google Sheets, no email, no schedule — just three rules that work on messy data, on your own laptop.
The walking skeleton, built in public. Including the two bugs we plant on purpose.
Where we left off
A CSV file we generate ourselves — deliberately messy.
Text printed on your screen, and one CSV report.
Three business rules, correct against data that fights back.
Environment Setup
Forty-five minutes of plumbing. Then we never think about it again.
Install checklist
- VS Code is open
The editor. Free. code.visualstudio.com - Node.js is installed
Check:node --version→ prints something likev22.x - Claude Code is installed
npm install -g @anthropic-ai/claude-code - You are logged in
Runclaudein the terminal, follow the browser login. - It answers you
Typehello, what model are you?and get a reply.
Every person in the room has seen Claude reply to them once. Not "nearly." Everyone.
Two ways to run Claude Code
| Option | What it is | For | Against | Verdict |
|---|---|---|---|---|
| VS Code extension | A Claude panel docked beside your files | You see the files change as they change. Friendlier for beginners. Diffs are visual. | Hides the terminal — which is where you eventually want to live. | ✅ CHOSENfor today |
| Terminal CLI | Type claude in any folder |
Works over SSH, on servers, in any editor. This is the real tool. | A blank black rectangle is terrifying on day one. | USE BOTHThe extension opens a terminal anyway. Same tool, two doors. |
Orient yourself inside VS Code
📄 CLAUDE.md
📄 create_sample_data.py
📄 rules.py
📄 movements.csv
Your project is a folder.
Nothing more mystical than that.
me what columns it has
✓ Read movements.csv (1,847 rows)
Columns: date, sku, type, qty_sqm, note
You type in English.
It reads, writes, and runs.
…output appears here…
④ Editor The big space in the middle,
where the code shows up.
You will read it. Rarely type in it.
Four regions. You'll use two of them 95% of the time: ② the Claude panel and ③ the terminal.
The terminal, demystified
The terminal is just chat with your computer.
And now Claude sits inside it, translating.
You already send text to a machine and get text back, all day. The only new thing is that this machine takes the message literally, and never guesses what you meant.
cd stock-alerts # go into the folder
python rules.py # run our program
claude # start Claude Code
First contact
Create a file called hello.py that prints today's date in a friendly format, then run it and show me the output.
- Claude created a file
- Claude ran it
- Claude showed you the result
- You typed one English sentence
hello.py in the editor. Three lines of Python.
You can read it. It says what it does. That is the whole language.The setup, slowly, by someone with infinite patience
We just did setup in forty-five minutes and some of you are still not green. This is the same ground, covered slowly, that you can pause and rewind at home. Play the first few minutes now; keep the link for tonight.
- Does your terminal look like theirs? If not, what's different?
- Which step did you skip?
- What does it look like when it works?
Common setup failures, and fixes
| Symptom | What it means | Fix |
|---|---|---|
node: command not found |
Node isn't installed, or the terminal was open before you installed it. | Close the terminal and open a new one. Really. That fixes it 70% of the time. Otherwise reinstall from nodejs.org. |
EACCES on npm install -g |
npm is trying to write to a folder your user doesn't own. | Don't reach for sudo. Ask Claude on your phone, or use
Node's official installer, which sets permissions correctly. |
| Login loop — browser opens, nothing happens | Usually a corporate VPN or an ad-blocker eating the callback. | Disable the VPN for two minutes. Try a different browser. Try tethering to your phone. |
| Corporate wifi blocks everything | A proxy is intercepting npm and the login callback. |
Tether to a phone hotspot. This is why the homework said "check this before you arrive." |
python: command not found |
macOS ships python3, not python. |
Type python3. Or ask Claude Code: "set up a Python
environment in this folder" — it will. |
Design the Data
Before Any Code
The client gave us a Friday Excel habit. Our first real job is turning a habit into a data model.
From a habit to a data model
Somebody, every Friday, opens a file and types what changed this week.
That habit already contains a data model. It's just implicit, undocumented, and living in one person's head. Our job is to write it down — and, while writing it down, to notice the places where the habit quietly loses information.
If Friday's file only shows today's totals, then last Tuesday's sale is gone forever. You can never answer "when did this last sell?"
Whether to record what things are or what happened to them. It sounds philosophical. It is the entire project.
What the client actually has
| A | B | C | D | E | F | |
|---|---|---|---|---|---|---|
| 1 | STOCK REPORT — updated Friday (please don't delete) | |||||
| 2 | Date | รหัส / Code | In | Out | qty (ตร.ม.) | notes |
| 3 | 3/7/26 | GRN-60x60-MATT | ✓ | 500 | from supplier, invoice 4471 | |
| 4 | 3/7/26 | MRB-80x80-POL | ✓ | 120 | Khun Somchai project | |
| 5 | TOTAL THIS WEEK | 620 | ← a totals row, in the middle | |||
| 6 | 10/7/26 | SLT-40x40-BLK | ✓ | 85 | reorder soon!! last one | |
| 7 | 10/7/26 | grn-60x60-matt | ✓ | 1,240 | big job | |
Every real business file looks like this. Yours does too.
Reading the mess
GRN-60x60-MATT and grn-60x60-matt are the same tile and two
different SKUs, as far as a computer is concerned.3/7/26 —
3 July or 7 March? Depends who typed it and where they grew up.The notes column
"reorder soon!! last one" is a human running rule #2 in their head and writing the answer in the margin. That's not mess — that's the requirement, confirming itself.
Keep the notes column. Don't parse it. Just carry it along.
Events vs snapshots
"We have 350 sqm of MRB-80x80-POL today."
One number. Overwritten every Friday. It tells you where you are.
"+500 in on 2 May. −150 out on 9 May."
Many rows. Never overwritten, only appended. It tells you how you got here.
Movements can rebuild any snapshot.
Snapshots
can never rebuild movements.
The same idea, in the industry's own words
What we've called "log the movements, derive the snapshot" has a name in the industry: event sourcing. You just invented it from first principles because rule 3 forced you to. Hearing it named is worth five minutes.
- Where does their "current state" come from?
- What question can they answer that a snapshot database cannot?
- What does it cost them? (There is always a cost.)
Rule 3 is impossible with snapshots
"Any SKU with no movement for more than 4 months."
Look at a snapshot. TRC-30x30-TERR: 640 sqm. Now answer the question:
when did it last sell?
You can't. The number 640 is identical whether that tile sold yesterday or has been untouched since February. The snapshot destroyed the only fact the rule needs — when things happened.
Snapshots? Impossible. Needs per-week in/out.
Snapshots? Works fine. This is the only rule a snapshot can serve.
Snapshots? Impossible. Needs the date of the last outbound movement.
What we write down
movements
One row per thing that happened. Append only. Never edited.
| date | sku | type | qty_sqm | note |
|---|---|---|---|---|
| 2026-07-03 | GRN-60x60-MATT | in | 500 | inv 4471 |
| 2026-07-03 | MRB-80x80-POL | out | 120 | project |
| 2026-07-10 | SLT-40x40-BLK | out | 85 | last one |
sku_master
One row per product. Facts that don't change weekly.
| sku | name | color | is_bestseller | low_stock_threshold |
|---|---|---|---|---|
| GRN-60x60-MATT | Granite 60×60 | Grey | TRUE | 200 |
| MRB-80x80-POL | Marble 80×80 | Ivory | TRUE | 200 |
| TRC-30x30-TERR | Terrazzo 30×30 | Sand | FALSE | 200 |
Every column earns its place
| Column | Which rule needs it | Where the decision came from |
|---|---|---|
| movements.date | Rules 1 and 3 | Rule 3 is impossible without it. Stored as ISO YYYY-MM-DD,
always. |
| movements.type | Rules 1, 2, 3 | One column, not two tick-boxes. Dead stock means no out —
Session 0, ambiguity 2. |
| movements.qty_sqm | Rules 1 and 2 | Units in the column name, so nobody ever wonders. sqm, always. |
| movements.note | No rule | Carried, never parsed. It's where the humans put context, and one day we'll be glad it's there. |
| sku_master.is_bestseller | Rule 2 | Session 0, ambiguity 1. The manual flag. The owner ticks it. Our "cheap reversible default" is now a real column. |
| sku_master.low_stock_threshold | Rule 2 | Session 0, ambiguity 3. Per-SKU, defaulting to 200. One extra column bought us every future variation. |
Two rows in this table are Session 0 decisions, landing as physical columns. That is what "architecture" was for.
What we deliberately did not model
Prices
No rule needs money. The moment one does, add a column.
Suppliers
Interesting for reordering. Not required by any of the three rules.
Warehouse locations
The client has one shop. Multi-location is a fantasy about a company they don't have.
Units other than sqm
Boxes, pieces, pallets. All real. All irrelevant, because the client thinks in sqm.
Five tiles, five stories
| SKU | What it is | Its situation on Friday 10 July 2026 | Its role in this course |
|---|---|---|---|
| GRN-60x60-MATT | Granite look, 60×60, matte, grey. Bestseller. | 1,240 sqm in stock. Sells every week. Boring, healthy. | The control. Nothing should ever fire for this tile. |
| MRB-80x80-POL | Marble look, 80×80, polished, ivory. Bestseller. | 180 sqm in stock, threshold 200. | Triggers rule 2. The alert that should fire. |
| TRC-30x30-TERR | Terrazzo, 30×30, sand. Not a bestseller. | 640 sqm sitting there. Last sold 6 Feb 2026 — 154 days ago. | Triggers rule 3. Real dead stock. Real money on a shelf. |
| WDX-20x120-OAK | Wood-look plank, 20×120, oak. New product. | Added 11 May 2026 — 60 days old. 900 sqm. Never sold. Not yet. | The trap. Rule 3 will accuse it of being dead. It's a newborn. |
| SLT-40x40-BLK | Slate, 40×40, black. Specialty. | Movements imply −35 sqm. Negative stock. Physically impossible. | The data error. Reality is broken and the app must cope. |
Generate data that fights back
Create create_sample_data.py that writes movements.csv and sku_master.csv. movements.csv columns: date, sku, type, qty_sqm, note - dates are ISO (YYYY-MM-DD), spanning 12 months ending 2026-07-10 - type is exactly "in" or "out" - about 30 SKUs, roughly 1,800 rows total, realistic weekly rhythm sku_master.csv columns: sku, name, color, is_bestseller, low_stock_threshold - low_stock_threshold defaults to 200 Include these five specific SKUs with these exact situations: 1. GRN-60x60-MATT bestseller, healthy, ends near 1,240 sqm 2. MRB-80x80-POL bestseller, ends at exactly 180 sqm 3. TRC-30x30-TERR not a bestseller, 640 sqm, last "out" on 2026-02-06 4. WDX-20x120-OAK first movement 2026-05-11, only "in", never sold 5. SLT-40x40-BLK movements sum to -35 sqm (a real data-entry error) Then run it and show me the last 10 rows of movements.csv.
Context. Exact column names. Exact date format. Exact scope. And five test cases specified by their outcome, not their implementation.
This is the Session 0 "good prompt" slide, in the wild.
We just planted the bugs we'll need later.
Testing with clean data teaches nothing. Clean data proves your code runs. Messy data proves your code is right — and, more often, proves that you never decided what "right" meant.
Both bugs we planted are real bugs from real businesses. Neither is a trick.
SLT-40x40-BLK — negative stock. Someone sold tiles they never recorded receiving.
WDX-20x120-OAK — a two-month-old product with no sales. Innocent. About to be accused of being dead.
Build the Three Rules
Each rule is a mini case study. Each one goes wrong on purpose.
Six steps, three times
"No movement for 4 months" → "no row with type='out' whose date is within 122
days of today." Nobody but you can do this translation, because only you know the business.
Every rule below has a failure moment, printed on a slide. If your version doesn't fail, your data is too clean — go back and dirty it.
Prompting for code, watched over someone's shoulder
We are about to write three prompts that must be exactly right, because a vague prompt produces confident, plausible, wrong stock rules. Watch how much of good prompting is just stating the context nobody told the machine.
- How much of their prompt is context, versus instruction?
- Do they say what not to do? (Our prompts always do.)
- Where does project memory live?
Weekly stock movement
The client said:
"Which SKU came in / went out and how much, each week."
For each week, for each SKU that had any movement: sum of in, sum of
out, and the net difference.
"week"
What, exactly, is a "week"?
Monday → Sunday
What every date library gives you for free. What Claude will use unless told otherwise. What standards bodies agree on.
Sunday → Saturday
What half the spreadsheet software on earth assumes. Equally defensible. Equally arbitrary.
Saturday → Friday
Because someone updates the Excel file every Friday. A "week" for this business is everything since the last time I opened this file.
This decision goes straight into CLAUDE.md. It will never be
re-litigated.
Prompt, and result
Create rules.py. Read movements.csv and sku_master.csv. Rule 1 — weekly movement summary: For each week, for each SKU with any movement, print total in, total out, and net. A week runs SATURDAY to FRIDAY, because the client updates their file every Friday. Do not use the ISO Monday-Sunday week. Print the most recent week only, as a table. Don't write any files yet.
Note the last line. An explicit non-goal, every time.
Low stock — and the number that doesn't exist
The client said:
"For best-selling SKUs, if remaining stock drops below 200 sq.m., alert so we can reorder."
Look at our two tables. There is no stock column. There never was. We chose
movements over snapshots, so current stock isn't stored — by design.
stock[sku] = sum(qty where type == "in")
- sum(qty where type == "out")
One line. Recomputed every run. Never stale, never wrong, never needs fixing.
sku_master:
is_bestseller and that SKU's own low_stock_threshold. The Session 0
decisions, doing their job.
The prompt
Add Rule 2 to rules.py — low stock. Current stock is not stored. Derive it per SKU: sum of "in" quantities minus sum of "out" quantities, across all of movements.csv. Then, for every SKU where is_bestseller is TRUE and derived stock is below that SKU's own low_stock_threshold (from sku_master.csv, default 200), print a LOW STOCK line showing the SKU, its stock, and its threshold. Print the derived stock for all five of our demo SKUs too, so we can sanity-check it.
"Print the derived stock for all five, so we can sanity-check it."
We didn't ask for the answer. We asked to see the working. Rule 2's alert would have been correct and useless without it.
It ran. And something is wrong.
Rule 2 fired correctly. MRB-80x80-POL is a bestseller at 180 sqm against a threshold of 200. Exactly the alert the client asked for.
SLT-40x40-BLK has −35 sqm of stock.
Negative square metres of tile. In the physical universe. In a warehouse.
It's their data. Now what?
Someone sold 85 sqm of black slate and never recorded the delivery that brought it in. The tiles exist. The row doesn't. Our arithmetic is perfect and our answer is impossible.
Crash
Stop the program. "Negative stock detected — aborting."
No. One bad row on a Friday night now means zero alerts sent, and MRB-80x80-POL quietly runs out.
Silently fix
Clamp it to zero. max(0, stock). Move on. Nobody's upset.
Absolutely not. Now the error is invisible and permanent. The missing delivery is never found. You have hidden a real-world problem inside a max() function.
Report it
Compute everything you can. Send every alert you can. Then add a data-quality warning to the bottom of the message.
Yes. The program keeps working. The human learns something is wrong. Both problems get solved by the person able to solve them.
Apps that touch reality need a "reality is broken" lane.
The -35 sqm for SLT-40x40-BLK is a real data-entry error in the client's records, not a bug in our code. Don't crash and don't clamp it to zero. Instead: collect any SKU whose derived stock is negative into a list of DATA QUALITY WARNINGS, print them in their own section at the end, and keep all other rules running normally.
Dead stock — "no movement for 4 months"
The client said:
"Any color/SKU with no movement for more than 4 months, alert."
"Movement" means outbound. Dead stock is stock that doesn't sell. Restocking a tile nobody wants makes it more dead, not less.
From the last outbound movement to today. Not to the end of the file, not to the last row — to today, the day the alert runs.
The prompt
Add Rule 3 to rules.py — dead stock. For each SKU, find the date of its most recent movement where type == "out". If that date is more than 122 days before today (2026-07-10 for our test data), print a DEAD STOCK line with the SKU, the days since it last sold, and its current derived stock. Sort by days-since-last-sale, worst first.
Everything in this prompt is correct. Everything the client asked for is here. It is about to be badly wrong.
One line. TRC-30x30-TERR, last sold 6 February, 154 days ago, 640 sqm of sand-coloured terrazzo gathering dust.
The false alert
"Your brand-new oak plank range, launched eight weeks ago, is dead stock. Discount it."
The client's trust in the alert dies at exactly this moment — and it never fully comes back.
Nobody thinks of this until the false alert fires. Not you, not Claude, not the client. It is not a failure of intelligence. It is a failure that only reality can teach.
The missing guard
WDX-20x120-OAK is a false positive. It has never sold because it launched 60 days ago, not because it's dead. Add a guard: a SKU can only be dead stock if it has EXISTED for at least 122 days. Treat a SKU's first movement date (of any type) as its birthday. A SKU younger than 122 days is never dead stock, no matter what. If it's older than 122 days and has never sold at all, that IS dead stock.
The requirement was never wrong. It was incomplete.
Said "no movement for 4 months" and meant it sincerely. They were not thinking about products launched last May, because when they wrote the sentence, they were picturing the terrazzo.
Implemented the sentence perfectly. It had no way to know that a SKU can be young. The requirement it was given did not contain that concept.
Ran it against real data, saw an oak plank accused of being dead, and flinched. That flinch is the job. That flinch is not automatable.
Output for today = your screen, plus one CSV
Someone in the room wants to connect Google Sheets right now. Someone else has already opened the docs for sending email.
No. The logic core works, on data that fought back, and it is testable in one second by one person with no credentials. That is a finished thing.
Also write the three sections (weekly summary, low stock, dead stock, data-quality warnings) to a file called weekly_report.csv, with a "section" column so all four fit in one file. Keep printing to the screen as well.
Because a CSV needs no credentials, no internet, and no permission from Google. When we swap it for a real sheet next session, only the last four lines of the script change.
Session 1 Close
What we have
Weekly summary. Sat→Fri, because the client's Friday habit beat the ISO standard.
Low stock, derived not stored — and a data-quality lane for when reality is broken.
Dead stock, with the age guard we only found by watching it accuse an innocent tile.
Start your CLAUDE.md
Today you made six decisions. Write them down where Claude will read them every single session, forever.
Create CLAUDE.md capturing the decisions we made today, so you never have to ask me again: - A week runs Saturday to Friday (client updates their file on Fridays). - Dead stock = no OUTBOUND movement for more than 122 days AND the SKU is at least 122 days old. - Low-stock thresholds are per-SKU, default 200 sqm. - Current stock is derived from movements, never stored. - Bad data is reported as a warning, never crashed on and never silently fixed. - All quantities are square metres (sqm).
The code is regenerable.
Delete rules.py. Open Claude Code. Say "rebuild rules.py from
CLAUDE.md."
You'll get your program back. You would not get today's arguments back — which is why they're the part you wrote down.
Next time, the sheet talks to the
script
and your phone buzzes.
Google Sheets, for real. The credential ceremony. And the thing the client actually asked — "how do we connect Claude to Google Sheets?" — turns out to have two different answers.
Email that a tired human can act on at 18:05 on a Friday. Then LINE, where the team actually lives.
A schedule that fires whether or not anyone remembered. And a cron expression that lies to you about what time it is.
Bring your laptop, your Google account, and the rules.py you built today.