Connect, Alert,
Automate
Three boxes still grey. Today we light them — and then we close the laptop and watch the thing run without us.
Ending with the retro: everything that went wrong, and what it taught us.
One box lit. Three to go.
The sheet, the API, the credential ceremony, and the round-trip.
An alert a tired human can act on. Then a schedule that never forgets.
The post-mortem. Four things went wrong. Each one taught us a principle.
Connecting to Google Sheets
The client's one throwaway question, taken seriously.
The client asked one question. It contained two.
It was the last line of the brief. An afterthought. It is the most interesting sentence in the document.
The script needs the data every Friday, with nobody present
Unattended. Scheduled. No human, no browser, no chat window. This needs the Google Sheets API.
The human wants to interrogate the data conversationally
"Which SKUs look dead?" "What sold most in June?" Asked out loud, answered in a chat. This needs MCP.
Three ways to connect code to a spreadsheet
| Option | What it is | For | Against | Verdict |
|---|---|---|---|---|
| Export CSV by hand | A human downloads the sheet each Friday and feeds it to the script | Zero setup. Works today. No credentials, no Google Cloud, no YAML. | The human is the integration. It breaks the automation goal completely, and it breaks the first Friday somebody goes on holiday. | RETIREDIt was exactly right for Session 1. It is exactly wrong now. |
| Sheets API + service account | Our Python script gets its own robot Google account, with read access to the sheet | Fully automatic. Standard practice everywhere. Free. Works at 3am with nobody logged in. | A one-time credential setup ceremony. About 30 minutes of clicking through consoles that were designed by different teams in different decades. | ✅ CHOSENThe production path. |
| MCP server for Google Sheets | Claude itself reads the sheet, in conversation, while you watch | Magical for exploration and ad-hoc questions. Zero code. You just ask. | It's Claude-in-the-loop. There is no Claude in the loop at 18:00 on a Friday. Wrong tool for unattended jobs. | ✅ ALSO USEDAs the exploration tool — not the automation. |
Two winners, because there were two questions. That is not a compromise — it's the correct answer.
Three words, in plain English
A door for programs
Google Sheets has a door humans use — the website, with buttons. It has a second door for programs: no buttons, no pictures, just "give me rows 1 to 500 of this tab."
An API is that second door. It needs a key, because anyone could knock.
A robot employee
Not you. A separate Google account that belongs to your script, with an email address like stock-bot@your-project.iam.gserviceaccount.com.
You share the sheet with the robot, exactly as you'd share it with a colleague. That's the whole idea.
Giving Claude hands
Model Context Protocol. A standard way to plug tools into Claude, so that during a conversation it can actually go and read your sheet rather than guessing.
You chat; it fetches. Wonderful with a human present. Useless without one.
Somebody else clicking through the same five screens
The next thirty minutes are the hardest and dullest of the course: five consoles, designed by five different teams. Watching someone do it once, end to end, makes the clicking far less disorienting when it's your turn.
- How many separate screens does the robot's key touch?
- Do they remember to share the sheet with the service account?
- What do they do with the JSON file afterwards?
Create a Google Cloud project
- Go to console.cloud.google.com
- Click the project dropdown, top left, next to "Google Cloud"
- New Project → name it
stock-alerts - Wait ~20 seconds. Make sure the dropdown now says
stock-alerts.
A folder for permissions and billing. Nothing runs in it. It exists so Google knows which things belong together, and so you can delete all of them at once.
Enable the Sheets API
- In the search bar at the top, type Google Sheets API
- Click the result (it's a product page, not a settings page)
- Press the big blue Enable button
- Wait. The page will reload into a dashboard with zero traffic on it.
Google has hundreds of APIs. They're all switched off by default, per project. You are turning on exactly one door and leaving the rest locked.
Hire the robot
- Search for Service Accounts (it lives under "IAM & Admin")
- Create service account
- Name it
stock-bot. Skip the optional "grant access" steps — those are about Google Cloud, not about your sheet. - Done. Now copy its email address. It looks like this:
Copy it somewhere. You need it in step 5, and step 5 is the one everybody forgets.
You created a new Google identity. It has an email address. It cannot log in to anything, it has no password, and right now it has access to nothing at all.
Download the robot's password
- Click your new
stock-botservice account - Go to the Keys tab
- Add key → Create new key → JSON
- A file downloads. Move it into your project folder and rename it
service-account.json.
{
"type": "service_account",
"project_id": "stock-alerts-472913",
"private_key_id": "a1b2c3...",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEv...
"client_email": "stock-bot@stock-alerts-472913
.iam.gserviceaccount.com",
"token_uri": "https://oauth2.googleapis.com/token"
}
Note client_email. That's the robot's address — the one you need
in step 5. It was in the file all along.
Share the sheet with the robot
- Open your Google Sheet
- Click Share, top right — the same button you'd use for a colleague
- Paste the robot's email:
stock-bot@…iam.gserviceaccount.com - Give it Editor (we'll write report tabs back). Viewer is enough if you only read.
- Untick "notify people". The robot does not read its email.
You share the sheet with it.
The robot can see exactly the sheets you shared with it and nothing else. Not your Drive. Not your email. Not your other spreadsheets.
To revoke it: remove it from the share list. Done. One click, and it is blind again.
The JSON key is a password. Treat it like one.
- Paste it into a chat window
- Email it to a colleague
- Commit it to git
- Put it in a slide deck (we didn't — that key above is fake)
- Add it to
.gitignorebefore your first commit - Keep it out of the code — load it from a file path or an environment variable
- In the cloud, store it as a secret (Session 2, later)
Set up .gitignore for this project so that service-account.json and any .env file can never be committed. Then show me the file.
Move the CSVs into a real sheet
- Create a new Google Sheet called Stock Alerts
- Rename the first tab to
movements. File → Import →movements.csv. - Add a second tab,
sku_master. Importsku_master.csv. - Share it with the robot (step 5, if you haven't).
- Copy the sheet's ID out of the URL — the long string between
/d/and/edit.
Two tabs in one sheet, or two separate sheets?
One sheet, two tabs. One thing to share with the robot, one ID to remember, one thing to back up. There is no benefit to splitting them and one more thing to forget.
.gitignore.Teach the script to read the sheet
Change rules.py so it reads its data from Google Sheets instead of the local CSV files. - Use the service account at service-account.json - Sheet ID: 1aB2cD3eF4gH5iJ6kL7mN8oP9qR - Tabs: "movements" and "sku_master" - Same columns as the CSVs, first row is headers Keep all three rules exactly as they are. Only the loading changes. Add the Python packages you need to requirements.txt. If the sheet can't be reached, print a clear error saying WHY - don't just crash with a stack trace.
This sentence is the whole reason Session 1 refused to touch Google Sheets. The rules don't know where their data came from, so swapping the source is a small, safe, boring change.
We asked for a good error message before we had an error. In about forty seconds, roughly a third of the room will be very glad we did.
Write the answers back into the sheet
Now write the results back to the same sheet. Create or overwrite a "weekly_report" tab with the weekly movement summary. Create an "alerts" tab and APPEND one row per alert: timestamp, alert_type (low_stock | dead_stock | data_quality), sku, detail. Append, don't overwrite - the alerts tab is our audit log. We want to be able to ask "did this fire last Friday?" in six months.
weekly_report is a snapshot — this week's numbers, replaced each run.
alerts is an event log — appended forever, never
overwritten.
The same distinction from Session 1, showing up in a completely different place. Once you see it, you see it everywhere.
The data round-trips
A program you directed into existence read a spreadsheet it was given permission to see, applied three business rules you defined, and wrote its conclusions back where a human can read them.
Nobody knows it happened. The answers are sitting in a tab. That's a pull medium — and we knew that in Session 0.
MCP: chatting with your own spreadsheet
Connect an MCP server for Google Sheets to Claude, and you stop writing code to ask questions. You just ask.
you ▸ which SKUs haven't sold since April?
claude ▸ Reading
the movements tab… 3 SKUs have no outbound movement since 1 April:
TRC-30x30-TERR (last sold 6 Feb), CRM-25x40-WHT (last sold 22 Mar),
and WDX-20x120-OAK — though that one only launched in May.
It noticed the oak plank caveat. Because you're there, in the loop, reading it.
- Exploring data before you know the rules
- One-off questions nobody will ever ask twice
- Sanity-checking what your script just wrote
- Letting a non-technical owner interrogate their own business
Run at 18:00 on Friday while everyone is at dinner. There is no conversation happening. There is nobody to read the answer.
MCP, from the people who wrote the protocol
I've described MCP as "giving Claude hands." That's a metaphor, and metaphors leak. Here is the protocol described by the people who designed it, so you can judge my metaphor for yourself.
- What problem existed before MCP that it solves?
- Who is present when an MCP tool runs?
- Would you build our Friday job on it? (We didn't. Listen for why.)
When to reach for which
| Sheets API + service account | MCP server | |
|---|---|---|
| Who is present? | Nobody. That's the point. | You are. That's the point. |
| When does it run? | On a schedule. Every Friday, forever. | When you feel like asking. |
| What does it produce? | The same answer, the same way, every time. | A conversation. Different every time — which is the feature and the bug. |
| Is it repeatable? | Yes. It's code. You can read it, test it, and blame it. | Not exactly. Ask twice, get two phrasings. |
| Use it for | The Friday job. Anything that must happen without you. | Exploration. Anything where the question is new. |
Alerts That Reach Humans
Now we build the push.
Everything we've built so far is pull
Someone must open it.
Someone must open it, then find the tab.
Someone must run the script and be looking at the screen.
An answer nobody sees
is not an answer.
Two ways to send an email
| Option | What it is | For | Against | Verdict |
|---|---|---|---|---|
| Gmail SMTP with an app password |
Your script logs into your own Gmail and sends as you | Zero signup. No new vendor. Works in fifteen minutes. Free forever at this volume. | Requires 2FA plus an "app password" — another credential ceremony. Gmail may rate-limit you. The alert comes from a person, not a system, which is subtly confusing. | ✅ CHOSENZero signup wins on a teaching day. |
| A transactional email API e.g. Resend, Postmark |
A service whose whole job is sending mail from programs | Built for this. Better deliverability. Logs, retries, a dashboard. Generous free tier. | A signup, an account, a domain to verify if you want to look professional. A vendor you now depend on. | RIGHT ANSWER FOR PRODUCTIONIf this were shipping to a paying client, choose this. |
A bad alert
Subject: Report
{"week": "2026-07-04/2026-07-10",
"movements": [{"sku": "GRN-60x60-MATT",
"in": 500, "out": 1240, "net": -740},
{"sku": "MRB-80x80-POL", "in": 0,
"out": 120, "net": -120}, ...],
"low_stock": [{"sku": "MRB-80x80-POL",
"stock": 180, "threshold": 200}],
"dead_stock": [{"sku": "TRC-30x30-TERR",
"days": 154}],
"warnings": [{"sku": "SLT-40x40-BLK",
"stock": -35}]}
- The subject line says nothing. It could be good news.
- It's a data dump, not a message.
- Everything is equally important, so nothing is.
- It never says what to do.
- An 18:05 Friday reader closes it and opens it Monday. Maybe.
A good alert
Subject: ⚠️ 1 SKU below reorder threshold · 1 dead · 1 data problem
⚠️ Reorder now — 1 bestseller is low
MRB-80x80-POL · Marble 80×80 Ivory
180 sqm left, threshold 200. Sold 120 sqm this
week — about 10 days of stock.
💤 Dead stock — 1 SKU
TRC-30x30-TERR · Terrazzo 30×30 Sand
No sales for 154 days. 640 sqm on the shelf. Consider a promotion or stop
reordering.
🔧 Data problems — 1
SLT-40x40-BLK shows −35 sqm, which is impossible. A delivery was probably never recorded. Please check the invoices.
Week Sat 4 Jul → Fri 10 Jul · 4 SKUs moved · full detail in the weekly_report tab
- The subject line is the summary
- Most urgent first, least urgent last
- Every section says what to do
- "About 10 days of stock" — a number we computed so a human doesn't have to
- The data dump moved to the sheet, where data dumps belong
You have to ask for a good alert
Send the results by email via Gmail SMTP. Read the address and app password from environment variables, never from the code. The email is read by a warehouse manager at 18:05 on a Friday, on their phone, while leaving. Write it for that person: - Subject line = the summary. Counts, not "Report". - Order sections by urgency: low stock, then dead stock, then data-quality problems. - Every section says what to DO, not just what is. - For low stock, also estimate days of stock left, using this week's outbound quantity. - Full tables stay in the sheet. Don't paste them into the email. - If nothing is wrong, send nothing at all.
"If nothing is wrong, send nothing at all."
An alert that arrives every Friday whether or not anything is wrong trains people to ignore it — and then it fails on the one Friday it mattered.
Push spends attention. Spend it only when you have something worth buying.
LINE — where the team actually lives
Email is where information goes to wait. In Thailand, the team's real nervous system is a LINE group, open all day, on everyone's phone.
The alert doesn't need to be seen. It needs to arrive where people already are, and where somebody can immediately reply "already ordered, arriving Tuesday."
- A LINE Developers account
- A Messaging API channel
- A channel access token (another secret — same rules)
- Invite the bot to the group; get the group ID
- One HTTP POST from Python
Automate the Friday Run
Question 4: what makes it run without a human?
We chose GitHub Actions. Now justify it.
A weekly job that runs for thirty seconds uses roughly 0.02% of the free tier. This will never cost anyone anything.
Nothing to patch, nothing to restart, no laptop that must be awake. It runs on a machine that appears for 40 seconds and then ceases to exist.
Which we should have done anyway. The "cost" of this option is a thing we wanted.
git / repository a folder whose entire history is remembered, and which can be copied to a server. GitHub is a website that hosts those folders.
Push the project to GitHub
Put this project on GitHub as a PRIVATE repository called stock-alerts. Before the first commit, double-check that .gitignore excludes service-account.json and .env, and show me exactly which files WILL be committed so I can approve the list.
- Runs
git init, stages, commits - Creates the repo (via the
ghCLI, if you have it) - Pushes
- Explains any of it, in plain English, if you ask
The robot's key can't be in the repo. So where?
GitHub has a vault. You paste the secret in once; the running job can read it; nobody — including you — can ever read it back out.
- Repo → Settings → Secrets and variables → Actions
- New repository secret
- Name:
GOOGLE_SERVICE_ACCOUNT_JSON
Value: the entire contents ofservice-account.json - Repeat for
GMAIL_ADDRESSandGMAIL_APP_PASSWORD
Code goes in the repo. Secrets go in a vault. The code asks the vault for the secret at the moment it runs, and never writes it down.
Every cloud platform on earth does this, with different button names.
The workflow file
name: Weekly stock alerts
on:
schedule:
- cron: "0 11 * * 5" # ← review THIS line
workflow_dispatch: # ← lets you run it by hand
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- name: Write credentials from the vault
run: echo '${{ secrets.GOOGLE_SERVICE_ACCOUNT_JSON }}' > service-account.json
- name: Run the rules
env:
GMAIL_ADDRESS: ${{ secrets.GMAIL_ADDRESS }}
GMAIL_APP_PASSWORD: ${{ secrets.GMAIL_APP_PASSWORD }}
run: python rules.py
"Every Friday at 11:00 UTC — or whenever I press the button — rent a Linux machine, put Python on it, install what we need, fetch the secrets from the vault, run our script, then throw the machine away."
Not to write YAML. Claude writes YAML. Your job is to review the cron line, because it is the only line that encodes a business decision.
workflow_dispatch is the "run it now" button. Insist on
it. A scheduled job you can't trigger by hand is a job you cannot test.Scheduling a job you never have to remember
Claude wrote our YAML and we only reviewed one line of it. Before you trust a schedule that runs without you for the next two years, it is worth seeing someone explain what that line does.
- What are the five fields of a cron expression?
- Do they mention which timezone the schedule runs in?
- How do they test it without waiting for Friday?
Cron speaks UTC. Your client doesn't.
cron: "0 18 * * 5""Friday at 18:00." Obviously.
cron: "0 11 * * 5"
It doesn't crash. Nothing turns red. GitHub reports a successful run, every week, forever.
It just produces a slightly wrong answer at a useless time, and nobody notices for a month.
Time zones are not a detail. Any time a computer and a human disagree about what "Friday evening" means, the computer wins and the human is confused.
Write the timezone in a comment. Always. # 11:00 UTC = 18:00
Bangkok
Press the button. Then let Friday be Friday.
- Repo → Actions tab
- Choose Weekly stock alerts
- Run workflow → the button that
workflow_dispatchgave you - Watch the log scroll. Wait for the email.
- Open the email on your phone — that's where it'll be read.
- It found the sheet (not a 403 — that's step 5 again)
- The row count looks right
- The
alertstab got new rows appended - The email actually sent
Close the laptop. Go home. On Friday at 18:00 Bangkok time, a computer you have never seen will run code you directed into existence, and someone's phone will buzz.
Retro: The Post-Mortem
This chapter is what makes it a case study and not a tutorial.
What went wrong, and what it taught us
| What happened | Why it happened | The principle it taught |
|---|---|---|
| SLT-40x40-BLK had −35 sqm of stock | A real delivery was never recorded. Our arithmetic was flawless; the world wasn't. | Apps that touch reality need a "reality is broken" lane. Report bad data. Never crash on it, never silently fix it. |
| We told the client their new oak range was dead stock | Rule 3 was implemented exactly as specified. The specification had never met a two-month-old product. | Requirements are incomplete, not wrong. Only running against real data finds the boundary. Show the bug, then fix it. |
| The Friday job fired at 01:00 Saturday, Bangkok | Cron is UTC. Nothing errored. The green checkmark lied to us for a month. | The worst bugs don't crash. Test the thing end-to-end, at the time and on the device where a human meets it. |
Thirty minutes lost to a 403 PERMISSION_DENIED |
We created the robot and never shared the sheet with it. Step 5 of five. | Credential ceremonies are a real cost. Budget for them, name them out loud, and never let a beginner conclude they're stupid. |
Decisions we'd revisit at 10× scale
| Today's choice | What breaks it | What we'd move to | Cost of the move |
|---|---|---|---|
| Google Sheets as the store | Tens of thousands of movement rows. Two people editing at once. One person typing "abc" into a quantity cell. | SQLite, then Postgres. Sheets survives as the input form. | DAYSBecause movements are rows, and rows move. We designed for this without paying for it. |
Manual is_bestseller flag |
300 SKUs. Nobody maintains the ticks. It quietly goes stale and rule 2 stops firing. | Computed from sales velocity — top N by outbound sqm over 90 days. | HOURSNothing downstream cares where the flag came from. That was the point of choosing a column. |
| Email alerts | Nobody acts on them. There is no record of who did what. | LINE with acknowledgment buttons — "Reordered ✓ / Ignore". | DAYSAnd now the alert has state, which means you need somewhere to store it. Question 1, again. |
| One threshold per SKU | Seasonality. A tile that sells 400 sqm/week in November and 40 in May needs two different answers. | Threshold as days of cover, not sqm. "Alert when less than 3 weeks of stock remains." | HOURSAnd it's a better rule. We'd have got it wrong on day one by guessing. |
Three of these four cost hours — because we chose reversible doors on purpose, back in Session 0.
What the client actually got
- One person opens an Excel file every Friday and types what changed
- Low stock is noticed when someone walks past an empty rack
- Dead stock is noticed during the annual count, if at all
- The 640 sqm of terrazzo has been sitting there since February
- Nobody knows about the missing slate delivery
- The Friday summary writes itself
- A bestselling SKU below its threshold pushes an alert to the team's phones
- Dead stock surfaces at 122 days, not at year end
- Data-entry errors get reported instead of silently compounding
- An append-only audit log of every alert ever fired
≈ $0 / month
Sheets free. GitHub Actions free tier. Gmail free.
2 days
Of learning-while-building, by people who could not code on Monday morning.
The CLAUDE.md
Because the decisions are the project, and the code is regenerable.
The four questions, one last time
Where does the data live?
Because the humans already lived there.
What turns data into answers?
Because Claude is excellent at it and beginners can read it.
How do answers reach a human?
Because alerts must push, never pull.
What makes it run without a human?
Because humans forget, and cron doesn't.
Where to go next
Build the dashboard
Now it's a legitimate bonus rather than a substitute for an alert. A single page that reads the sheet. Claude will build it in an hour.
Reorder quantity suggestions
Don't just say "low." Say "order 600 sqm — that's 8 weeks at the current rate." The data is already there.
Connect the POS
Stop typing movements by hand. Every sale becomes an out row automatically. This
is the version where the Friday Excel habit finally dies.
Teach a colleague
Using this deck. If you can walk someone through the four questions and defend the options tables, you have the skill. That was always the deliverable.
The skill is not writing code.
The skill is
thinking clearly about the problem
and directing Claude well.
Every line of Python. The YAML. The email formatter. The sample data. Roughly 400 lines, correct on the first or second attempt, every time.
That a week starts on Saturday. That dead means outbound. That a young SKU can't be dead. That bad data gets reported, never hidden. That the alert must push.
When the code was perfect and the answer was wrong. Four times. That flinch is the whole job — and it is the one part nobody can do for you.
Thank you. Now go and ask somebody the four questions.