Someone writes "Can we find 30 minutes early next week? I'm in London." You open your calendar, count five hours and thirty minutes, check you aren't proposing 4:30 a.m. for them, and type three options. It takes four minutes. You do this twenty times a month.
An AI calendar assistant can take most of that over, with one rule: the model handles words and code handles time. The model reads the email and writes the reply. A short Python script finds the free slots. You approve the draft. This post gives you the prompts, a script I ran on 2026-10-31 with invented calendar data, and the rules that keep it from double-booking you.
I did not connect a live calendar or run a live model. The extraction step is a stub that returns the JSON a model might return, the prompts are untested templates, and the busy list in the script is invented. Calendar APIs aren't covered; export your busy times however your calendar allows and feed the script.
What is the workflow?
Five steps. Only two use a model.
- Classify. Is this email a scheduling request at all? (A model, or a Gmail filter on words like "call", "meet", "slot".)
- Extract. A model turns the email into constraints as JSON: duration, date range, time zone, hard no-go days, ambiguities.
- Compute. Code finds free slots inside your working hours, with buffers, and ranks them.
- Draft. A model writes a short reply using only the slots the code gave it.
- Send. You do, after reading it.
If you already label mail with a script, the Gmail triage workflow shows a safe label-only pattern you can reuse for step 1.
How do you extract scheduling constraints from an email?
Untested template. The structure matters more than the wording: fixed fields, an explicit "unknown", and a place to put doubts.
You read one email and extract scheduling constraints. Return JSON only.
Today's date is [TODAY, YYYY-MM-DD] and my time zone is Asia/Kolkata.
Fields:
- duration_min: integer, or null if not stated
- earliest_date, latest_date: YYYY-MM-DD, resolved from phrases like "next week" using today's date, or null
- sender_tz: an IANA name such as Europe/London, only if the email states a city, zone or offset. Otherwise null.
- prefers: short phrase, e.g. "mornings", or null
- hard_no: list of YYYY-MM-DD dates they said they cannot do
- confidence: "high" only if every field above is stated or clearly implied; otherwise "low"
- ambiguities: list of short strings for anything you had to guess or could not resolve
Rules: never guess a time zone. "Next Friday" is ambiguous, so list it under ambiguities and resolve nothing.
Email:
[PASTE EMAIL BODY]
Notice that the prompt gives today's date. Models don't know it unless you tell them, and "next week" is meaningless without it. Notice also that "next Friday" is a trap: some people mean this coming Friday, others mean the Friday of next week. The prompt tells the model to flag it instead of picking.
Here's the stub JSON I used for the demo, which is illustrative, not a captured model answer:
{"duration_min": 30, "earliest_date": "2026-11-02", "latest_date": "2026-11-04",
"sender_tz": "Europe/London", "prefers": "afternoon_for_sender", "hard_no": ["2026-11-03"],
"confidence": "high", "ambiguities": []}
How does code find the free slots?
The script reads the constraints, builds half-hour start times inside working hours (10:00 to 18:00 IST in the demo), removes anything that overlaps a busy block plus a 15-minute buffer, skips weekends and the sender's no-go days, then ranks what is left. It uses zoneinfo from the standard library, so daylight saving is the library's problem and not yours.
import json
from datetime import datetime, timedelta, time
from zoneinfo import ZoneInfo
IST = ZoneInfo("Asia/Kolkata")
BUSY = [ # my calendar, IST, invented
("2026-11-02 10:00", "2026-11-02 11:30"),
("2026-11-02 15:00", "2026-11-02 16:00"),
("2026-11-03 09:30", "2026-11-03 18:00"),
("2026-11-04 14:00", "2026-11-04 15:00"),
]
WORK = (time(10, 0), time(18, 0))
BUFFER = timedelta(minutes=15)
def parse(s):
return datetime.strptime(s, "%Y-%m-%d %H:%M").replace(tzinfo=IST)
def free_slots(c):
busy = [(parse(a) - BUFFER, parse(b) + BUFFER) for a, b in BUSY]
dur = timedelta(minutes=c["duration_min"])
day = datetime.fromisoformat(c["earliest_date"]).date()
last = datetime.fromisoformat(c["latest_date"]).date()
out = []
while day <= last:
if day.isoformat() not in c["hard_no"] and day.weekday() < 5:
t = datetime.combine(day, WORK[0], IST)
end = datetime.combine(day, WORK[1], IST)
while t + dur <= end:
if all(t + dur <= b0 or t >= b1 for b0, b1 in busy):
out.append(t)
t += timedelta(minutes=30)
day += timedelta(days=1)
return out
def pick(slots, c, n=3):
tz = ZoneInfo(c["sender_tz"])
def score(s):
return 0 if 13 <= s.astimezone(tz).hour < 17 else 1 # their afternoon
chosen, days = [], set()
for s in sorted(slots, key=lambda s: (score(s), s)):
if s.date() not in days: # one slot per day
chosen.append(s)
days.add(s.date())
if len(chosen) == n:
break
return sorted(chosen)
The main block parses the JSON, stops and prints the ambiguities if confidence isn't high, and prints the chosen slots in both zones. Output from my run:
free 30-min starts: 20
NOTE: no slot meets the sender's afternoon preference inside my working hours
Mon 02 Nov 12:00 IST | 06:30 GMT
Wed 04 Nov 10:00 IST | 04:30 GMT
Read that output closely, because it holds the real lesson. The sender wanted their afternoon. Their 13:00 to 17:00 in London is 18:30 to 22:30 in India, which is outside the 10:00 to 18:00 working window, so the code says so and offers the best of what exists. A model asked to "find a time that suits both of us" would likely have written a cheerful reply with a slot at 19:00 anyway. The 3 November was removed by their own no-go date, and it was blocked on my side all day too, so only two days survive. You get two options, not three, and the code doesn't pad.
A clock detail worth knowing: London was on GMT on those dates (UK clocks changed on 25 October 2026 by the usual last-Sunday rule), and the library output agrees. If the date range had been a week earlier, the same IST slot would show an hour later in London. That is exactly the error that hand-written offsets cause.
What should the reply-drafting prompt look like?
Give the model the finished slots and forbid it from adding any. Untested template.
Write a short email reply from me to [NAME]. Two sentences of context at most.
Offer ONLY these slots, each shown in both time zones exactly as given:
[PASTE THE SLOT LINES FROM THE SCRIPT]
Rules:
- Do not add, change or round any time. Do not mention any other time.
- If the note says a preference could not be met, say so in one plain sentence and offer to look at other days.
- No flattery, no "hope you are well". Sign off with my first name.
- Do not include a meeting link. I will add it.
If the script found zero slots, skip this prompt and use the second one below.
Write a short email reply to [NAME] saying I have no room in their window of [EARLIEST] to [LATEST].
Ask for one more window of three working days, or offer an async alternative (a written answer or a 10 minute voice note).
Keep it under 60 words.
For wording that doesn't read like a template, how to prompt AI to sound human has the edits I'd apply.
What rules keep it from going wrong?
- The assistant never sends and never creates events. It writes a draft. If you later add an auto-send, restrict it to a narrow case you've tested and log every send. The human approval gate for tool calls is the same idea in code.
- Low confidence means a question, not slots. If
confidenceisn'thighorambiguitieshas any entry, the draft is a clarifying question. - Slots come only from code. Check the draft: every time in it must appear in the script output. A one-line test (does each time string in the draft occur in the slot lines?) catches a model that "helpfully" adds a fourth option.
- Treat the email as untrusted input. A scheduling email can contain instructions aimed at your assistant. The model reads it only to extract constraints, with no tools, and that limits the damage. See prompt injection for why.
- Keep what leaves your mailbox small. Send the model the one email body, not your calendar. Your busy times stay on your machine in this design, which is a quiet advantage over handing the model a calendar connector. Check the vendor's retention terms before you paste client mail anywhere.
When does this not pay off?
If you schedule fewer than five meetings a week, a booking link does the job with zero AI. This workflow earns its keep when requests arrive in prose, with odd constraints, from people who won't click a link: clients in other zones, event organisers, journalists, vendors.
It also doesn't solve the human part. Whether you should take a meeting is a judgement. You might extend the extraction prompt with a "purpose" field and a "should I take this?" note, but I'd keep that as a hint beside the draft and not an automatic decline.
What to do first
Run the script with your own busy export and three real requests, and compare its slots to what you would have offered. If it disagrees with you, find out whose error it is before you add a model. After a week of drafts, count how often you changed the times (it should be near zero, since code chose them) and how often you changed the words. Tune the drafting prompt on the second number. For related patterns, the meeting notes workflow covers what happens after the call, and AI email automation prompts has broader reply templates.



