Lesson #2 - Building Your First Real Automation (From Idea to Working Script)

πŸ’‘
Time to complete: 90 minutes
Goal: Build a multi-step automation that connects two platforms via API

Welcome Back!

In Lesson 1, you installed Claude Code and built your first quick win. You proved you can use the terminal.

Now we're building something real β€” automations that connect multiple tools and save us hours of manual work every week.

How this lesson works
I recorded three videos of me building this automation from scratch. You'll watch what I do, then read the technical explanations of what's actually happening under the hood. The videos show the "how." The written sections explain the "what" and "why."

By the end, you'll understand:

  • What an API actually is (and why it matters)
  • The difference between reading data, creating data, and updating data
  • How to connect any two tools that have APIs
  • What to do when things break

Let's go.

Video 1: What We're Building (and Why)

πŸ“Ή
Setting Up the Automation Challenge (5 min)
In this video, I explain the problem. I run an invite-only newsletter called Field Notes, and I want to automate the process of creating member accounts. I walk through the tools involved (Tally for forms, Airtable for tracking, Ghost for the newsletter) and what we're going to automate.

The key takeaway: I want to do two things:

  1. Check who's already a member from the first batch and update Airtable to reflect that
  2. Set up a way to approve new members and create their accounts in batch β€” no more doing it one by one

First, Let's Talk About APIs

Before we go further, you need to understand what an API is. I'll keep this simple.

What is an API?

API stands for Application Programming Interface. Think of it as a waiter at a restaurant.

  • You (the customer) want food from the kitchen
  • You can't walk into the kitchen yourself
  • The waiter takes your order, brings it to the kitchen, and returns with your food

An API works the same way:

  • Your script wants data from Airtable
  • Your script can't access Airtable's database directly
  • The API takes your request, gets the data, and returns it to your script

Every modern tool β€” Airtable, Ghost, Slack, Notion, Salesforce β€” has an API. That's how they talk to each other.

The Four Things You Can Do With an API

APIs let you do four basic operations (often called CRUD):

OperationWhat it doesReal example
CreateAdd new dataCreate a new member in Ghost
ReadGet existing dataFetch all records from Airtable
UpdateChange existing dataMark a record as "Member = true"
DeleteRemove dataDelete a test account

In technical terms, these map to HTTP methods:

OperationHTTP MethodWhat your script sends
CreatePOST"Here's a new member, please add them"
ReadGET"Give me all the records"
UpdatePATCH or PUT"Change this field on this record"
DeleteDELETE"Remove this record"

You don't need to memorize this. But when you see Claude's plan mention "GET request" or "POST request," now you know what it means.

API Keys: Our Script's Password

APIs need to know who's making the request. That's where API keys come in.

An API key is like a password that identifies your script. When your script talks to Airtable, it says: "Hey, it's me, here's my key, please give me access."

This is why API keys are secret. Anyone with your key can access your data. Never share them. Never put them in code that others can see.

Where API Keys Live: The .env File

For this project, I stored the API keys in a special file called .env (short for "environment"). It looks like this:

AIRTABLE_API_KEY=example1234567890abcdef
AIRTABLE_BASE_ID=app1234567890
GHOST_ADMIN_API_KEY=abc123:xyz789

Your scripts read from this file, but the file itself never gets shared. This keeps your keys safe.

πŸ’‘
Note: The .env file isn't just for API keys. You can use it to store any configuration your script needs to run β€” Base IDs, table names, URLs, and other settings all belong here. This makes your scripts reusable: someone else can use the same script with their own .env file, no code changes needed, because you're not "hard-coding" your API keys or personal data into the script itself.

Video 2: Script to Sync Existing Data

πŸ“Ή
Connecting Airtable and Ghost (30 min)
In this video, I build the entire first script from scratch. I show the flowchart, ask Claude to read the API docs, handle some dependency issues (pip vs pip3), and watch the records update at once.

Watch for these moments:

  • [~2:00] The flowchart explaining what Script 1 does
  • [~5:00] Asking Claude to read API documentation first
  • [~8:00] The .claude/settings.json file that stores permissions
  • [~15:00] Claude asking which language to use (Python)
  • [~20:00] The pip vs pip3 dependency issue
  • [~25:00] Running the sync and watching records update

What Script 1 Actually Does (Technical Breakdown)

You don't need to know how to code to build with Claude Code, but it's helpful to understand what's happening under the hood.

The Problem

I had 40 people already subscribed in Ghost (my newsletter platform). But my Airtable database was out of date and did not know about them β€” the "Member" checkbox was unchecked for everyone. I needed to sync this data.

The Solution: Three Steps

Step 1: GET all members from Ghost
        ↓
Step 2: GET all records from Airtable  
        ↓
Step 3: Compare emails, then PATCH Airtable
        to set Member=true for matches

Step 1: Reading from Ghost (GET Request)

The script sends a GET request to Ghost's Admin API:

GET https://your-site.ghost.io/ghost/api/admin/members/
Authorization: Ghost {JWT_TOKEN}
Accept-Version: v5.0

Ghost's Admin API uses JWT (JSON Web Token) authentication.

The script takes your Admin API key (which looks like key_id:secret), splits it apart, and generates a short-lived JWT token that expires in 5 minutes. This is more secure than sending the raw API key with each request.

Ghost responds with a list of all members β€” their emails, names, when they joined, etc. This data comes back as JSON, which looks like:

{
  "members": [
    {"email": "alice@example.com", "name": "Alice"},
    {"email": "bob@example.com", "name": "Bob"}
  ]
}

The script extracts just the emails: ["alice@example.com", "bob@example.com", ...]

Step 2: Reading from Airtable (GET Request)

Same idea. The script sends a GET request to Airtable:

GET https://api.airtable.com/v0/BASE_ID/TABLE_NAME
Authorization: Bearer YOUR_API_KEY

Airtable responds with all records, including their email addresses and current "Member" status.

Step 3: Compare and Update (PATCH Request)

Now the script has two datasets:

  • Ghost members (40 emails)
  • Airtable records (91 records with emails and IDs)

It compares them: "Which Airtable records have emails that exist in Ghost?"

For each match, it sends a PATCH request to update that record:

PATCH https://api.airtable.com/v0/BASE_ID/TABLE_NAME
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

{
  "records": [
    {"id": "rec123abc", "fields": {"Member": true}},
    {"id": "rec456def", "fields": {"Member": true}}
  ]
}

This changes the "Member" checkbox from false to true.

Why Batching Matters

Airtable has rate limits β€” you can only update 10 records per request. If you try to update 36 records one at a time, you'll hit the limit and get errors.

So the script batches: it groups records into sets of 10 and sends them together. That's why you see "Updating in batches of 10..." in the output.

The Upsert Optimization

After running the script once, I realized a problem: if I ran it again, it would try to update all 36 records again, even though they're already marked as members.

That's wasteful. So I asked Claude to modify the script to only update records that need updating β€” skip the ones already marked true.

This is the difference between:

  • Insert all: Update every matching record, every time
  • Upsert: Only update records that have actually changed

This makes the script idempotent β€” you can run it multiple times and it only changes what actually needs changing.

At small scale, it doesn't matter. At hundreds or thousands of records, it matters a lot.

Now You Try

The specific tools don't matter β€” the pattern works for any two platforms with APIs. Here's the general approach:

Step 1: Set Up Your Environment File

Create a .env file with your API keys and configuration:

PLATFORM_A_API_KEY=your_key_here
PLATFORM_A_BASE_ID=your_base_id

PLATFORM_B_URL=https://your-site.com
PLATFORM_B_API_KEY=your_key_here

Step 2: Have Claude Learn the APIs

Before writing any code, point Claude to the documentation for each platform:

Read this documentation extensively and learn everything about 
the [Platform Name] API: [link to docs]

Step 3: Connect to Each Platform

Test each connection separately before trying to sync them:

Connect to my [Platform] via API. Use the variables in the 
.env file. Fetch all records and show me a count.

Step 4: Build the Sync Script

Use Plan Mode (Shift+Tab) and describe what you want:

Create a script that:
1. Fetches data from Platform A (GET)
2. Fetches data from Platform B (GET)
3. Compares them by [matching field, e.g., email]
4. Updates Platform A to reflect what exists in Platform B (PATCH)
5. Shows me a summary and asks for confirmation before updating
6. Batches updates to respect API rate limits

Claude will ask clarifying questions about field names, filters, and preferences. Answer them, and let it build the script.

βœ“ Checkpoint: Script #1 Complete!
Before continuing, make sure you understand:
1. What is a GET request? (Reading/fetching data from an API)
2. What is a PATCH request? (Updating existing data)
3. Why do we batch updates? (API rate limits β€” Airtable allows 10 per request)
4. What's an upsert? (Only update records that have changed)

Video 3: Script to Create New Members

πŸ“Ή
Creating Ghost Members + Troubleshooting (45 min)
In this video, I build Script 2, which creates new members in Ghost based on approvals in Airtable. This video is longer because things go wrong β€” the magic link API returns 404 errors and Claude goes down some rabbit holes.

Watch for these moments:

  • [~3:00] The flowchart for Script 2 (different from Script 1)
  • [~10:00] Adding filters: only process records where Invite=true AND Member=false
  • [~20:00] The 404 error on the magic link endpoint
  • [~30:00] Claude trying multiple fixes that don't work
  • [~40:00] Finally getting it working

This video shows troubleshooting in real time. Things break. Claude doesn't always get it right the first time. Persistence matters.


What Script 2 Actually Does (Technical Breakdown)

Script 2 is more complex because it writes to Ghost, not just reads.

The Problem

New people request access via a form. I review them in Airtable and mark the ones I approve. But then I still have to manually create each account in Ghost.

The Solution: Three Steps

Step 1: GET records from Airtable
        (where Invite=true AND Member=false)
        ↓
Step 2: For each record, POST to Ghost
        (create new member + send welcome email automatically)
        ↓
Step 3: PATCH Airtable
        (set Member=true so we don't process again)

Step 1: Reading with Filters (GET Request)

We don't want all records β€” just the ones I've approved but haven't processed yet:

GET https://api.airtable.com/v0/BASE_ID/TABLE_NAME
    ?filterByFormula=AND(NOT({Member}), {Invite})
Authorization: Bearer YOUR_API_KEY

The filterByFormula parameter tells Airtable to only return matching records. The formula AND(NOT({Member}), {Invite}) means "Member is unchecked AND Invite is checked."

Step 2: Creating a Member with Automatic Email (POST Request)

For each approved person, the script sends a POST request to Ghost. Here's the key insight: Ghost can automatically send a welcome email when you create a member β€” you just need to include the right query parameters.

POST https://your-site.ghost.io/ghost/api/admin/members/?send_email=true&email_type=signup
Authorization: Ghost {JWT_TOKEN}
Content-Type: application/json

{
  "members": [{
    "email": "newperson@example.com",
    "name": "New Person",
    "labels": [
      {"name": "API", "slug": "api"},
      {"name": "Product Manager", "slug": "product-manager"}
    ],
    "note": "Interested in AI for support teams"
  }]
}

The magic is in those query parameters:

  • send_email=true β€” tells Ghost to email the new member
  • email_type=signup β€” sends the signup/welcome email template

One request, two things happen: member created and email sent.

Note on labels: Ghost expects labels as objects with both name and slug fields, not just strings. The script always adds an "API" label to track members created via automation, plus their role from Airtable.

Step 3: Updating Airtable (PATCH Request)

Finally, we mark records as processed so we don't create duplicate accounts next time:

PATCH https://api.airtable.com/v0/BASE_ID/TABLE_NAME
Authorization: Bearer YOUR_API_KEY

{
  "records": [
    {"id": "rec123abc", "fields": {"Member": true}},
    {"id": "rec456def", "fields": {"Member": true}}
  ]
}

Like in the first script, updates are batched in groups of 10 to respect Airtable's rate limits.

The Complete Flow

For each approved record:

  1. Create member in Ghost
  2. If successful, add to "success" list
  3. If failed, log error, continue to next record
  4. After all records processed, mark successful records as Member=true in Airtable

When Things Go Wrong: The 404 Saga

In the latest video, you watched me hit a wall. I was trying to send welcome emails to new members, but my approach wasn't working.

What I Tried First (The Wrong Approach)

I initially thought I needed two separate API calls:

  1. POST to create the member
  2. POST to a "magic link" endpoint to send the email

The second call kept failing:

Creating member... βœ“ Created
Sending magic link... βœ— Error: 404 Not Found

Claude tried several fixes β€” different endpoint URLs, different headers, different payloads. Nothing worked.

What Actually Fixed It

I knew it was possible β€” I'd gotten it working that morning with a different script. But right as I was preparing to stop the troubleshooting process, Claude figured it out on its own. It created a test member β€” and the email came through.

The solution wasn't to fix the magic link endpoint β€” it was to realize I didn't need it at all.

πŸ’‘
When Claude gets stuck, here's my advice:
1. Don't give up after one failure. It often needs a few attempts.
2. Give it one more chance. Sometimes Claude finds the answer right when you're about to lose faith.
3. Go back to the source. "Read the docs again" often reveals a simpler approach.
4. Share what you know. "I know this is possible" gives Claude useful context.
5. Look for simpler solutions. Sometimes the fix isn't debugging your complex approach β€” it's finding a simpler one.

Now You Try

The second script is about creating new records and triggering actions when you do.

The Pattern

Most "approval workflow" automations follow this structure:

1. GET records from Platform A (with filters for "approved" items)
2. For each record, POST to Platform B (create something new)
3. PATCH Platform A (mark as processed so you don't duplicate)

The Prompt Template

Use Plan Mode (Shift+Tab) and adapt this to your tools:

Create a script that:

1. Fetches records from [Platform A] where [approval field] = true 
   AND [processed field] = false (GET with filters)
2. For each record:
   - Creates a new [thing] in [Platform B] (POST) with:
     - [Field mapping: which fields go where]
   - Triggers [any automatic actions, like sending an email]
3. Updates [Platform A] to mark records as processed (PATCH)
4. Skips failed records and continues processing
5. Shows a summary before and after
6. Asks for confirmation before creating anything

Key Questions Claude Will Ask

  • What are the filter conditions? (e.g., "Approved = true AND Created = false")
  • What fields should map to what? (e.g., "Email from column A, Name from column B")
  • What should happen when a record fails? (Skip and continue, or stop entirely?)
  • Should it ask for confirmation? (Yes, especially while you're learning)

Pro Tip: Check the Docs for Bonus Features

Many APIs can do more than just create a record. Ghost, for example, can send a welcome email automatically if you add the right parameters to your POST request. Stripe can send receipts. Notion can notify users.

Before building, ask Claude:

What optional parameters does [Platform B]'s API support 
when creating a [thing]? Can it trigger emails or notifications automatically?

You might save yourself an extra API call πŸ˜„


Quick Reference

API Operations

What you want to doHTTP MethodExample
Get data from a platformGETFetch all Airtable records
Create something newPOSTAdd a new member to Ghost
Update existing dataPATCHChange Member field to true
Delete somethingDELETERemove a test account

The Scripts We Built

ScriptWhat it doesAPI operations
airtable_fetch.pyConnects to Airtable, shows record countGET
ghost_fetch.pyConnects to Ghost, shows member countGET
sync_members.pySyncs existing Ghost members to AirtableGET + GET + PATCH
create_ghost_members.pyCreates new Ghost members from AirtableGET + POST + POST + PATCH

Adapting This For Your Work

You probably don't have a Ghost newsletter. But the pattern works for any two tools with APIs:

The formula:

  1. GET data from Source A
  2. GET data from Source B (optional, for comparison)
  3. POST to create new records, or PATCH to update existing ones
  4. PATCH Source A to mark records as processed

Examples:

Syncing data (Script 1 pattern):

  • Sync customer feedback from Typeform β†’ Notion
  • Compare Intercom contacts with HubSpot and flag mismatches
  • Update a master spreadsheet with ticket volumes from Zendesk
  • Reconcile Stripe subscriptions with your internal customer database

Creating records (Script 2 pattern):

  • Create Salesforce contacts from form submissions
  • Turn escalated support tickets into Jira issues automatically
  • Add new hires from your HRIS to Intercom as teammates
  • Create draft knowledge base articles from frequently asked questions
  • Send Slack alerts when CSAT scores drop below a threshold
  • Generate Notion tasks from feature requests tagged in your help desk

Combining data from multiple sources:

  • Generate weekly reports from multiple data sources
  • Build a customer health dashboard pulling from CRM + support + billing
  • Create a daily digest of open tickets, pending approvals, and SLA risks

If the tools have APIs (most do), you can connect them.


Glossary

TermWhat it means
APIApplication Programming Interface β€” how tools talk to each other
API KeyA password that identifies your script to a service
GETAn API request that reads/fetches data
POSTAn API request that creates new data
PATCHAn API request that updates existing data
JSONThe format data travels in (looks like {"key": "value"})
EndpointThe URL your script talks to (e.g., api.airtable.com/v0/...)
Rate limitMaximum requests allowed per time period
BatchGrouping multiple operations into one request
UpsertUpdate if exists, insert if new β€” avoids duplicates
.env fileWhere you store secret API keys
DependenciesPackages your script needs to run (installed via pip)

Troubleshooting

"Module not found" error

This means our script needs packages that aren't installed yet. You can run this command to fix: pip3 install requests python-dotenv

Python scripts often rely on external packages. requests handles API calls, python-dotenv reads your .env file. These don't come pre-installed.

"Command not found: python" or "pip"

Your computer is looking for python but you have python3 installed (or vice versa). Use python3 instead of python: python3 your_script.py

Same for pip β€” use pip3 instead of pip. Macs and newer systems often have Python 3 installed as python3 rather than python. It's annoying, but once you know, you know.

Script runs but finds 0 records

Your filters aren't matching anything in your database. Things to check:
1. Field names are case-sensitive. "Member" is not the same as "member"
2. Check your actual data. Do you have any records where Invite = true AND Member = false?
3. Test without filters first. Ask Claude to fetch ALL records to confirm the connection works

Debugging prompt: Fetch all records from Airtable without any filters. Show me the first 3 records with all their fields.

This helps you see exactly what field names exist and what the data looks like.

401 Unauthorized

The API doesn't recognize your credentials.Things to check:

  1. Typos in your .env file. Even one wrong character breaks it
  2. Extra spaces. API_KEY = abc123 won't work β€” remove spaces around the =
  3. Expired or revoked keys. Generate a new one and update your .env
  4. Wrong key type. Some platforms have multiple keys (read-only vs admin). Make sure you're using the right one

Quick test: Copy your API key from the .env file and paste it somewhere β€” did you accidentally grab extra characters or miss some?

404 Not Found

The API endpoint doesn't exist, or you're calling it wrong. Common causes:

  1. Typo in the URL. Check for missing slashes, wrong version numbers
  2. Wrong endpoint entirely. You might be calling /members/ when it should be /users/
  3. The resource doesn't exist. You're trying to update a record ID that isn't real

Debugging prompt:

Show me the exact URL and headers you're using for this API call.

Then compare it against the official documentation.

429 Too Many Requests (Rate Limited)

You're hitting the API too fast. Add batching and delays. Ask Claude:
Modify the script to batch requests and add a small delay between batches to avoid rate limits.

Why this happens: APIs limit how many requests you can make per minute/hour. Airtable allows 5 requests per second. If you're updating 100 records one at a time without delays, you'll get blocked.

You did it! See you in Lesson 3 πŸ˜„