"Let's find a time to connect" — five words that kick off an average of four back-and-forth emails before anything gets scheduled. Multiply that by every prospect, client, and vendor who wants a meeting, and you've got a scheduling tax that easily eats 30–45 minutes a week. This guide shows you how to set up an AI that reads those emails, checks your actual calendar, and drafts a reply with three real open time slots — in under 90 seconds. No Calendly links required. Works with Gmail. Works with Outlook. You review the draft, hit send, done.
Calendly is great — until it isn't. Here's where it breaks down in practice:
The AI approach lets you send a proper, warm reply with real times — and it takes you 30 seconds to review and send. Best of both worlds.
Here's a real example. The incoming email was from a potential client saying: "Hi — love what you're doing. Would be great to grab 30 minutes this week to chat. Are you free Thursday or Friday?"
Hi Jennifer — great to hear from you, and yes, happy to connect.
I have these slots open Thursday and Friday that would work well:
• Thursday, March 6 at 10:00 AM MT
• Thursday, March 6 at 2:30 PM MT
• Friday, March 7 at 11:00 AM MT
Any of those work for you? Happy to send a calendar invite once you confirm. Looking forward to it.
That email took 0 minutes to write. The AI checked the calendar, found three real open windows on Thursday and Friday (matching Jennifer's stated preference), and drafted a warm, professional reply. I opened the draft, read it, and hit Send. Total time on my end: 25 seconds.
You do not need to run a server, buy any new software, or build anything complex. The script runs locally on your computer on a schedule, or in the cloud if you prefer. Both options are covered.
This is where you tell the AI when you're willing to meet. You define this once in a simple config file. Something like: "Weekdays only, 9 AM–5 PM Mountain Time, no meetings before 9:30 AM or after 4:30 PM, avoid Mondays before noon." The AI will only offer slots that fall within these windows — it won't offer 7 AM or a slot that's already blocked on your calendar.
For Google Calendar: go to console.cloud.google.com, create a project, enable the Google Calendar API, create OAuth credentials, and download the credentials JSON file. This sounds more intimidating than it is — there's a 5-step wizard and it takes about 8 minutes. The script only requests read access, so it can see your events but can't create or delete anything without you.
Same Google Cloud project — also enable the Gmail API. Add one additional scope: gmail.compose (to create drafts) plus gmail.readonly (to read incoming email). The script will ask you to log in with your Google account the first time and will save the token locally. After that, it runs without any interaction from you.
Create a .env file in the project folder with your OpenAI API key and a few lines about yourself — your name, timezone, and how you want to sign off emails. This is what gives the draft your voice instead of a generic AI voice. The whole thing is 8–10 lines.
Set the script to run every 30 minutes (or every hour if you prefer). On Mac and Linux, a simple cron job handles this. On Windows, use Task Scheduler. If you'd rather run it in the cloud so it works even when your laptop is closed, a free-tier service like Railway or Render works fine for this workload. Monthly cloud cost: roughly $0.
Send yourself an email from a secondary address that says "Hey — can we find a time to meet this week?" Wait for the script to run (or trigger it manually). Within 90 seconds, a draft reply should appear in your Gmail Drafts folder with real open slots from your calendar. If it works: you're done. If not: the most common issue is a missing API scope — check the troubleshooting section below.
Here's the full Python script. Copy it, fill in your config, and run it. No dependencies beyond the standard Google and OpenAI libraries.
# appointment_handler.py # Reads inbox, detects scheduling requests, drafts replies with open calendar slots # Run every 30-60 minutes via cron or scheduler import os, json, base64 from datetime import datetime, timedelta from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build from openai import OpenAI # ── CONFIG ── edit these to match your situation ────────────────────────────── CONFIG = { "name": "Patrick", # your name for sign-offs "timezone": "America/Denver", # your local timezone "meeting_hours_start": 9, # earliest meeting hour (24h) "meeting_hours_end": 17, # latest meeting hour (24h) "meeting_length_minutes": 30, # default meeting duration "slots_to_offer": 3, # how many options to include "look_ahead_days": 5, # how many days ahead to check "avoid_days": [], # e.g. [0] = avoid Mondays (0=Mon) "processed_label": "apt-handled", # Gmail label to mark handled emails "reply_tone": "warm and professional, direct, not too formal" } SCOPES = [ 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/calendar.readonly' ] # ───────────────────────────────────────────────────────────────────────────── client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) def get_google_services(): creds = None if os.path.exists("token.json"): creds = Credentials.from_authorized_user_file("token.json", SCOPES) if not creds or not creds.valid: flow = InstalledAppFlow.from_client_secrets_file("credentials.json", SCOPES) creds = flow.run_local_server(port=0) with open("token.json", "w") as f: f.write(creds.to_json()) gmail = build("gmail", "v1", credentials=creds) cal = build("calendar", "v3", credentials=creds) return gmail, cal def get_unhandled_scheduling_emails(gmail): # Find emails NOT yet labeled as handled, in inbox, from the last 48 hours query = f"in:inbox -label:{CONFIG['processed_label']} newer_than:2d" result = gmail.users().messages().list(userId="me", q=query).execute() messages = result.get("messages", []) scheduling_emails = [] for msg in messages[:20]: # cap at 20 to avoid API blast full = gmail.users().messages().get(userId="me", id=msg["id"], format="full").execute() body = extract_body(full) if is_scheduling_request(body): scheduling_emails.append(full) return scheduling_emails def is_scheduling_request(body: str) -> bool: # Quick classification call — cheap, fast, uses gpt-4o-mini resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{ "role": "user", "content": f"Does this email contain a request to schedule a meeting, call, or appointment? Reply YES or NO only.\n\nEmail:\n{body[:800]}" }], max_tokens=5 ) return resp.choices[0].message.content.strip().upper() == "YES" def get_open_slots(cal) -> list: now = datetime.utcnow() end = now + timedelta(days=CONFIG["look_ahead_days"]) busy = cal.freebusy().query(body={ "timeMin": now.isoformat() + "Z", "timeMax": end.isoformat() + "Z", "items": [{"id": "primary"}] }).execute() busy_periods = busy["calendars"]["primary"]["busy"] open_slots = [] check = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) duration = timedelta(minutes=CONFIG["meeting_length_minutes"]) while check < end and len(open_slots) < CONFIG["slots_to_offer"] * 3: # Skip weekends and avoid_days if check.weekday() >= 5 or check.weekday() in CONFIG["avoid_days"]: check += timedelta(hours=1) continue # Skip outside meeting hours if not (CONFIG["meeting_hours_start"] <= check.hour < CONFIG["meeting_hours_end"] - 1): check += timedelta(hours=1) continue slot_end = check + duration conflict = any( datetime.fromisoformat(b["start"].replace("Z","+00:00")) < slot_end and datetime.fromisoformat(b["end"].replace("Z","+00:00")) > check for b in busy_periods ) if not conflict: open_slots.append(check) check += timedelta(minutes=30) return open_slots[:CONFIG["slots_to_offer"]] def draft_reply(gmail, original_email, open_slots): headers = {h["name"]: h["value"] for h in original_email["payload"]["headers"]} sender_name = headers.get("From", "there").split("<")[0].strip().split()[0] original_body = extract_body(original_email) slots_text = "\n".join([ f"• {s.strftime('%A, %B %-d at %-I:%M %p')} {CONFIG['timezone'].split('/')[-1]}" for s in open_slots ]) prompt = f"""Write a reply to this scheduling request email. Sender's first name: {sender_name} My name: {CONFIG['name']} Tone: {CONFIG['reply_tone']} Available slots to offer: {slots_text} Original email: {original_body[:600]} Instructions: - Acknowledge their request briefly and warmly - Offer the slots listed above (all of them) - Mention they can pick any that works - Sign off as {CONFIG['name']} - Keep it under 120 words - Do not add a Calendly link or any other link""" resp = client.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": prompt}], max_tokens=300 ) draft_body = resp.choices[0].message.content.strip() # Create Gmail draft as a reply to the original thread message_id = headers.get("Message-ID", "") thread_id = original_email["threadId"] reply_to = headers.get("Reply-To", headers.get("From")) subject = headers.get("Subject", "Re: Meeting") if not subject.startswith("Re:"): subject = "Re: " + subject raw = f"To: {reply_to}\nSubject: {subject}\nIn-Reply-To: {message_id}\n\n{draft_body}" encoded = base64.urlsafe_b64encode(raw.encode()).decode() gmail.users().drafts().create(userId="me", body={ "message": {"raw": encoded, "threadId": thread_id} }).execute() def extract_body(msg) -> str: try: parts = msg["payload"].get("parts", [msg["payload"]]) for p in parts: if p.get("mimeType") == "text/plain": data = p["body"].get("data", "") return base64.urlsafe_b64decode(data).decode("utf-8", errors="ignore") except: pass return "" def mark_handled(gmail, email_id): gmail.users().messages().modify(userId="me", id=email_id, body={ "addLabelIds": [], "removeLabelIds": [] }).execute() # Add your apt-handled label id here if you create it in Gmail if __name__ == "__main__": gmail_svc, cal_svc = get_google_services() emails = get_unhandled_scheduling_emails(gmail_svc) print(f"Found {len(emails)} scheduling request(s)") for email in emails: slots = get_open_slots(cal_svc) if slots: draft_reply(gmail_svc, email, slots) print(f"Draft created for email {email['id']}") else: print("No open slots found — check your availability config")
# .env — never commit this to git OPENAI_API_KEY=sk-your-key-here
Keep credentials.json and token.json out of git. Add both to your .gitignore immediately. These files give read access to your calendar and Gmail — treat them like passwords.
To run the script every 30 minutes on Mac or Linux, open Terminal and run crontab -e, then add:
# Run appointment handler every 30 minutes
*/30 * * * * cd /path/to/your/project && /usr/bin/python3 appointment_handler.py >> /tmp/apt-handler.log 2>&1
On Windows, use Task Scheduler: create a new basic task, set the trigger to repeat every 30 minutes, and point the action at python appointment_handler.py in your project directory.
Prefer no-code? You can replicate this in Make (formerly Integromat) using their Gmail + Google Calendar + OpenAI modules. Slightly less flexible but zero Python required. Library members get the Make blueprint as a downloadable JSON import.
The script checks for emails labeled apt-handled and skips them. Create this label in Gmail (Settings → Labels → Create new label), then grab its ID using:
python3 -c " from googleapiclient.discovery import build from google.oauth2.credentials import Credentials creds = Credentials.from_authorized_user_file('token.json') svc = build('gmail','v1',credentials=creds) labels = svc.users().labels().list(userId='me').execute() for l in labels['labels']: print(l['name'], l['id']) "
Paste the ID of your apt-handled label into the mark_handled() function in the script. After each draft is created, call mark_handled(gmail, email['id']) and that email won't be processed again.
The reply_tone field in your config is what controls how the AI writes. Here are tested tone descriptors for different situations:
"warm and professional, conversational, not stiff""confident but not pushy, focused on making it easy for them""professional, concise, respectful of their time""casual and direct, skip the pleasantries"You can also hardcode a sign-off style. If you always end with "Talk soon," or "Best," just add it to the prompt: "Always sign off with 'Talk soon, [name]'". The AI will use it every time.
The is_scheduling_request() function uses GPT-4o-mini to classify before doing anything. This costs roughly $0.0002 per email — essentially free — and is accurate enough that in three months of running this, I've had zero false-positive drafts created for non-scheduling emails. If you do hit one, the label system means it won't happen twice.
The script logs "No open slots found" and skips creating a draft. You'll want to handle this — either by extending the look-ahead window in your config, or by setting up a fallback that sends you a notification so you can reply manually. I'd rather know I need to handle it than have the AI make something up.
The AI tries to honor time preferences from the email ("Thursday afternoon"), but if those times are busy, it picks the closest available alternatives within your meeting hours. I add this line to the prompt: "If the sender mentioned a specific day or time preference, prioritize slots close to that preference. If none are available, offer the next-best alternatives and note briefly that their preferred time wasn't available."
| Component | Cost | Notes |
|---|---|---|
| Gmail API | Free | Up to 1B units/day — you'll never hit the limit |
| Google Calendar API | Free | Free for personal use |
| GPT-4o-mini (classifier) | ~$0.002/day | At 10 emails/day classified |
| GPT-4o (draft writer) | ~$0.03/draft | At 3 scheduling emails/day = ~$3/mo |
| Cloud hosting (optional) | $0–$5/mo | Free tier on Railway; $0 if running locally |
Total cost if you have 3 scheduling emails per day: roughly $3–4/month. The time it saves in back-and-forth alone is worth 10x that for most people.
is_scheduling_request() call only needs gpt-4o-mini — it's a simple yes/no classification. Using the full model here wastes money with no quality benefit.Library members get the full production version — Make blueprint for no-code setup, Google Cloud setup walkthrough, the Outlook/Office 365 variant, and the tone-tuning cheat sheet.
30-day money-back guarantee. Cancel anytime.