Agent Playbook, build tutorial
A walkthrough of how to build a scheduling agent that books meetings, protects focus time, and resolves calendar conflicts across Google Calendar and Microsoft Outlook: it parses a plain request, computes the slots that work for everyone in their own timezone, proposes them, and books the meeting idempotently, while a person approves any invite that leaves your domain. Every code step is shown in three frameworks, Google ADK, the Claude SDK, and LangGraph, so you can build it on the stack your team already knows. The code here is the agent's core logic; standing it up in production also means adding the setup, integrations, and hardening around it.
Finding one slot that works for three busy people in three timezones takes a thread of emails, and that is before anyone gets the hour wrong. Meetings creep across the day until there is no unbroken stretch left to actually do the work, focus blocks get overwritten by whoever books first, and two invites occasionally land on the same slot. None of this is hard, exactly, it is just constant, and it lands on the people who can least spare the time.
A scheduling agent does the arranging. It reads everyone's free/busy, computes the slots that genuinely work, proposes them, and books the meeting, all while comparing time in one unambiguous zone so the hour is right, and protecting deep-work time so the calendar is not all fragments. The one thing it never does is send an external invite without a person behind it. It pairs with the email and inbox and meeting-notes playbooks.
You do not build the agent loop from scratch. A framework gives you the moving parts, and you supply the logic: a step is a model plus an instruction plus a typed schema or a few tools. The code tabs on the build steps show the same parse, availability, and propose flow three ways.
Google ADK composes the steps with a SequentialAgent and a Runner, the Claude SDK drives a tool-use loop, and LangGraph builds an explicit graph. The free/busy intersection, the timezone maths, the idempotent booking, and the conflict check are deterministic and identical in all three; only the orchestration and the model calls differ.
Every request flows through one loop: parsed, then turned into common free slots across attendees, then proposed. On accept the meeting is booked idempotently, with external invites held for approval; focus rules and a conflict check guard the booking, and the whole thing runs in UTC and is audited.
The calendars to manage, with OAuth access to Google Calendar or Microsoft Graph, and each attendee's home timezone.
A model API key (Gemini, Claude, or both), your calendar provider, the channel requests arrive on (email or chat), and a meeting-link provider.
Your focus-time and meeting-cap rules, which bookings are automatic versus approved, DPDP Act 2023 handling, and which calendars stay out of scope.
Install the packages and set your Gemini key. That is enough to run the runnable file below, which parses a request, computes timezone-aware free slots, and drafts a proposal locally. For production you also connect, with OAuth, your Google Calendar or Outlook and a meeting-link provider.
# Python 3.10 or newer
python -m venv venv && source venv/bin/activate
# The runnable path below uses Google ADK + Gemini:
pip install google-adk google-genai pydantic
# Your Gemini key from aistudio.google.com/apikey:
export GEMINI_API_KEY="your-key-here" | Layer | Pick | Why |
|---|---|---|
| Calendar connection | Google Calendar API and Microsoft Graph (Outlook) | Read free/busy and create events in whichever calendar each attendee uses. The same availability engine works across both, and every read and write is keyed in UTC. |
| Request parser | A model (Gemini 2.5 Flash, Claude) over the email or chat text | Pull who, how long, the window, the constraints, and the timezone out of a plain request like 'a 30 minute call with the Mumbai team next week, mornings their time'. The code tabs show this three ways. |
| Availability engine | Deterministic: timezone-aware free/busy intersection | Convert every busy block to UTC, intersect across attendees, and only then render candidate slots back in each attendee's own zone. Maths, not a model guess, so it is testable and never off by an hour. |
| Proposer and reply handler | A model with your tone, plus a fixed reply classifier | Draft the proposal message and classify each reply into accept, decline, reschedule, or other, so the back-and-forth resolves to a chosen slot. The code tabs show both three ways. |
| Booking | Deterministic: create the event, send invites, external invites on approval | Create one event idempotently with an idempotency key so a retry never makes a duplicate, attach the meeting link, and hold any invite that leaves your domain for a person to approve. |
| Focus-time and conflict rules | Deterministic rules over the calendar | Block deep-work, cap meetings per day, and detect double-bookings, so the calendar protects time instead of just filling it. |
The parse, propose, and assemble steps show the same logic in Google ADK, the Claude SDK, and LangGraph. Use the tabs to switch. The calendar intake, the free/busy intersection, the booking, the focus rules, and the conflict check are deterministic Python and identical everywhere. These are the building blocks, and they call your own systems (the calendar, the meeting-link provider) by name; the complete file you can run today is in "Put it together" below.
Start with the connection, not the cleverness. The agent reads each attendee's free/busy through the Google Calendar API or Microsoft Graph for Outlook, and converts every busy block to UTC the moment it arrives. Storing and comparing in UTC is the single most important correctness decision: it is the one timezone where intersection is unambiguous. Rendering back into a local zone happens only at the end. Deterministic, and the same in every framework.
from datetime import datetime, timezone
def read_busy(cal, attendees: list[str], start, end) -> dict:
"""Return each attendee's busy blocks, normalised to UTC."""
resp = cal.freebusy().query(body={
"timeMin": start.isoformat(), "timeMax": end.isoformat(),
"items": [{"id": a} for a in attendees]}).execute()
out = {}
for a, cal_data in resp["calendars"].items():
out[a] = [(_utc(b["start"]), _utc(b["end"])) # always store in UTC
for b in cal_data["busy"]]
return out
def _utc(iso: str) -> datetime:
return datetime.fromisoformat(iso).astimezone(timezone.utc)
# Microsoft Graph: POST /me/calendar/getSchedule returns the same idea.
# Everything downstream compares in UTC; local time is only for display. A request arrives as plain language, 'a 30 minute call with Priya and the Mumbai team next week, their morning', so before anything else the agent turns it into a typed object: the attendees, the duration, the date window, any constraints, and the timezone the request is phrased in. Because the schema is typed, the output is data the availability engine can use, not prose. The tabs show the same parse in Gemini, Claude, and LangChain.
from google import genai
from google.genai import types
from pydantic import BaseModel
client = genai.Client()
class Request(BaseModel):
attendees: list[str]; duration_min: int
window_start: str; window_end: str # ISO dates
timezone: str; constraints: str | None = None
def parse_request(text: str) -> Request:
res = client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Extract the scheduling request. Today is 2026-07-01.\n{text}"],
config=types.GenerateContentConfig(
response_mime_type="application/json", response_schema=Request))
return res.parsed # a typed Request object from anthropic import Anthropic
client = Anthropic()
SCHEMA = {"type": "object", "properties": {
"attendees": {"type": "array", "items": {"type": "string"}},
"duration_min": {"type": "integer"},
"window_start": {"type": "string"}, "window_end": {"type": "string"},
"timezone": {"type": "string"}, "constraints": {"type": "string"}},
"required": ["attendees", "duration_min", "window_start",
"window_end", "timezone"]}
def parse_request(text: str) -> dict:
res = client.messages.create(model="claude-opus-4-8", max_tokens=512,
tools=[{"name": "save_request", "input_schema": SCHEMA}],
tool_choice={"type": "tool", "name": "save_request"},
messages=[{"role": "user",
"content": f"Extract the scheduling request. Today is 2026-07-01.\n{text}"}])
return res.content[0].input from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel
class Request(BaseModel):
attendees: list[str]; duration_min: int
window_start: str; window_end: str
timezone: str; constraints: str | None = None
parser = ChatGoogleGenerativeAI(
model="gemini-2.5-flash").with_structured_output(Request)
def parse_request(text: str) -> Request:
return parser.invoke(f"Extract the scheduling request. Today is 2026-07-01.\n{text}") This is the heart of the agent, and it is deterministic. With every busy block already in UTC, the agent walks the search window in fixed steps and keeps only the slots where no attendee is busy, which is the intersection of everyone's free time. Candidate slots are computed in UTC and rendered in each attendee's own zone only for display, so a slot is never off by an hour and never lands in someone's night. The same in every framework.
from datetime import timedelta
from zoneinfo import ZoneInfo
def free_slots(busy: dict, window, duration_min: int, zones: dict) -> list[dict]:
step = timedelta(minutes=15); need = timedelta(minutes=duration_min)
slot, out = window[0], [] # window is (start, end) in UTC
while slot + need <= window[1]:
end = slot + need
clash = any(b0 < end and slot < b1 # overlap test, all in UTC
for blocks in busy.values() for b0, b1 in blocks)
if not clash:
out.append({"utc": slot, "local": { # render per attendee
a: slot.astimezone(ZoneInfo(z)).strftime("%a %H:%M")
for a, z in zones.items()}})
slot = end
else:
slot += step
return out
# Intersection happens in UTC; the only place local time appears is the label. With slots in hand, the model writes a short proposal in your tone offering two or three options, and then the agent listens. When a reply comes back, the model classifies it into a fixed set, accept, decline, reschedule, or other, and that label drives the next move: an accept goes to booking, a reschedule recomputes slots, anything unclear falls to a person. The tabs show the proposal and the reply classifier three ways.
from google import genai
client = genai.Client()
REPLY = ["accept", "decline", "reschedule", "other"]
def propose(slots: list, attendees: str, tone: str = "warm and brief") -> str:
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Write a {tone} message offering these times to {attendees}. "
f"Ask them to pick one.\n{slots}"]).text
def classify_reply(text: str) -> str:
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Classify this reply into one of {REPLY}. Label only.\n{text}"]
).text.strip() from anthropic import Anthropic
client = Anthropic()
def propose(slots: list, attendees: str, tone: str = "warm and brief") -> str:
res = client.messages.create(model="claude-opus-4-8", max_tokens=400,
messages=[{"role": "user", "content":
f"Write a {tone} message offering these times to {attendees}, "
f"asking them to pick one.\n{slots}"}])
return res.content[0].text
def classify_reply(text: str) -> str:
res = client.messages.create(model="claude-opus-4-8", max_tokens=64,
tools=[{"name": "set_reply", "input_schema": {"type": "object",
"properties": {"reply": {"type": "string", "enum": [
"accept", "decline", "reschedule", "other"]}},
"required": ["reply"]}}],
tool_choice={"type": "tool", "name": "set_reply"},
messages=[{"role": "user", "content": f"Classify this reply: {text}"}])
return res.content[0].input["reply"] from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel
from typing import Literal
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash") # or ChatAnthropic(...)
class Reply(BaseModel):
reply: Literal["accept", "decline", "reschedule", "other"]
classifier = llm.with_structured_output(Reply)
def propose(slots: list, attendees: str, tone: str = "warm and brief") -> str:
return llm.invoke(f"Offer these times to {attendees}, {tone}, "
f"ask them to pick one.\n{slots}").content
def classify_reply(text: str) -> str:
return classifier.invoke(f"Classify this reply: {text}").reply Once a slot is agreed, the agent books it, and this is where idempotency matters most. Every booking carries an idempotency key built from the attendees, the slot, and the request id, so a retry or a duplicated webhook can never create a second event. Internal meetings book straight away; any invite that leaves your domain waits for a person to approve, because an external calendar invite is not something to send on a guess. Deterministic, and the same in every framework.
def book(cal, slot: dict, attendees: list[str], req_id: str) -> dict:
key = idem_key(req_id, slot["utc"], attendees) # attendees + slot + request
if store.booked(key): # idempotent: never twice
return store.event(key)
external = [a for a in attendees if not internal(a)]
if external and not approved(req_id): # invite leaves the domain
approval_queue.add(req_id, slot, external) # wait for a person
return {"status": "awaiting_approval"}
event = cal.events().insert(calendarId="primary", body={
"start": {"dateTime": slot["utc"].isoformat(), "timeZone": "UTC"},
"end": {"dateTime": slot["end"].isoformat(), "timeZone": "UTC"},
"attendees": [{"email": a} for a in attendees]}).execute()
store.save(key, event); audit.log("booked", req_id, event["id"])
return event A calendar that only ever fills up is a worse calendar. Before the agent offers a slot, it applies your focus-time rules: deep-work blocks are off limits, the day has a cap on the number of meetings, and there is a minimum gap between them. These are plain rules over the calendar, deterministic and easy to tune, so the agent protects time rather than just filling whatever is open. The same in every framework.
from datetime import time
FOCUS = (time(9, 0), time(11, 0)) # daily deep-work block, local time
MAX_MEETINGS = 5 # cap per day
def allowed(slot_local, meetings_today: int) -> bool:
t = slot_local.time()
if FOCUS[0] <= t < FOCUS[1]: # never book over deep work
return False
if meetings_today >= MAX_MEETINGS: # cap the day
return False
return True
def filter_slots(slots: list, zones: dict, counts: dict) -> list:
owner, z = next(iter(zones.items())) # the calendar being protected
return [s for s in slots
if allowed(s["utc"].astimezone(ZoneInfo(z)), counts[owner])]
# Focus time is checked in the owner's local zone, where 9 to 11 means 9 to 11. Even with care, two events can race for the same slot when invites cross, so the agent checks for overlap before it writes and reconciles after. Two events that overlap in UTC are a conflict, full stop, and the agent surfaces them: a genuine double-booking is flagged for a person, while a clear winner (an accepted invite over a tentative hold) can be resolved by rule. Comparing in UTC is what makes the overlap test reliable. The same in every framework.
def conflicts(events: list[dict]) -> list[tuple]:
"""Return overlapping pairs. All times compared in UTC."""
ev = sorted(events, key=lambda e: e["start"]) # start, end are UTC
clashes = []
for i in range(len(ev) - 1):
a, b = ev[i], ev[i + 1]
if b["start"] < a["end"]: # they overlap
clashes.append((a, b))
return clashes
def resolve(a: dict, b: dict):
loser = a if a["status"] == "tentative" else b # accepted beats tentative
if a["status"] == b["status"]:
return review_queue.add((a, b), "double_booked") # a person decides
cal.events().delete(calendarId="primary",
eventId=loser["id"]).execute()
audit.log("conflict_resolved", loser["id"]) The pieces run on each new request and each reply. ADK composes parse, availability, and propose under a SequentialAgent and a Runner, the Claude SDK runs them in a tool-use loop, and LangGraph builds an explicit parse-availability-propose-book graph. Whichever you pick, the same guardrails apply: every time is stored and compared in UTC and rendered in the attendee's zone, every booking is idempotent so no event is ever created twice, any invite that leaves your domain waits for approval, calendar data is handled in line with the DPDP Act 2023, whose core obligations phase in through 2027, and every read, proposal, and booking is written to an immutable audit log.
from google.adk.agents import SequentialAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
scheduling_pipeline = SequentialAgent(name="scheduling_pipeline",
sub_agents=[parser, availability, proposer]) # each an LlmAgent with the tools
runner = Runner(agent=scheduling_pipeline, app_name="scheduling",
session_service=InMemorySessionService())
def guard(req: dict, slot: dict, attendees: list) -> str | None:
if store.booked(idem_key(req["id"], slot["utc"], attendees)):
return "already_booked" # idempotent: never double-book
if any(not internal(a) for a in attendees) and not approved(req["id"]):
return "needs_approval" # external invite waits for a person
return None
# Run on each request / reply via webhook or cron. UTC throughout; local only to show. from anthropic import Anthropic
client = Anthropic()
TOOLS = [parse_tool, freebusy_tool, propose_tool, book_tool] # JSON schemas
def run_scheduling(messages: list):
while True:
resp = client.messages.create(model="claude-opus-4-8", max_tokens=2048,
tools=TOOLS, messages=messages)
if resp.stop_reason != "tool_use":
return resp
messages.append({"role": "assistant", "content": resp.content})
for b in resp.content:
if b.type == "tool_use":
out = dispatch(b.name, b.input) # book holds external invites
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": b.id, "content": str(out)}]}) from langgraph.graph import StateGraph, START, END
g = StateGraph(dict)
g.add_node("parse", lambda s: {"req": parse_request(s["text"])})
g.add_node("availability", lambda s: {"slots": free_slots(
read_busy(s["req"].attendees), s["window"], s["req"].duration_min, s["zones"])})
g.add_node("propose", lambda s: {"msg": propose(s["slots"], s["req"].attendees)})
g.add_edge(START, "parse")
g.add_edge("parse", "availability")
g.add_edge("availability", "propose")
g.add_edge("propose", END)
scheduling_pipeline = g.compile() # booking is a guarded step; external waits
Here is a runnable file. It parses a scheduling request, computes the common
free slots across two attendees in different timezones (comparing in UTC and
showing each in their own zone), and drafts a proposal, and it does not book
anything. The free/busy data is inline; production reads the calendar, as in
the build steps. Save it as main.py and run python main.py.
# main.py -- run: python main.py
# A runnable reference: parses a scheduling request, computes common free slots
# across attendees (timezone-aware, compared in UTC), and proposes times. It does
# NOT book anything (external invites always need approval). The free/busy data is
# inline; production reads Google Calendar or Microsoft Graph, as in the build steps.
import asyncio
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
from google import genai
from google.genai import types
from google.adk.agents import LlmAgent
from google.adk.runners import InMemoryRunner
client = genai.Client() # reads GEMINI_API_KEY
# ---- inline free/busy, in UTC (replace with the Calendar / Graph API) ----
# Two attendees, each a list of (busy_start, busy_end) in UTC.
DAY = datetime(2026, 7, 2, tzinfo=timezone.utc)
def at(h, m=0):
return DAY.replace(hour=h, minute=m)
BUSY = {
"asha (Asia/Kolkata)": [(at(4, 30), at(6, 0)), (at(9, 0), at(10, 0))],
"ben (Europe/London)": [(at(8, 0), at(9, 30)), (at(12, 0), at(13, 0))],
}
WINDOW = (at(3, 30), at(13, 30)) # the search window, in UTC
# --------------------------------------------------------------------------
LABELS = ["one_to_one", "team_sync", "external", "interview"]
def parse_request(text: str) -> str:
"""Classify the scheduling request into one fixed type."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Classify this scheduling request into one of {LABELS}. "
f"Reply with the label only.\n{text}"]).text.strip()
def free_slots(duration_min: int = 30) -> str:
"""Intersect everyone's free time in UTC, then render in each attendee's zone."""
step = timedelta(minutes=15)
need = timedelta(minutes=duration_min)
slot = WINDOW[0]
found = []
while slot + need <= WINDOW[1] and len(found) < 3:
end = slot + need
clash = any(b_start < end and slot < b_end
for busy in BUSY.values() for b_start, b_end in busy)
if not clash:
ist = slot.astimezone(ZoneInfo("Asia/Kolkata")).strftime("%H:%M")
lon = slot.astimezone(ZoneInfo("Europe/London")).strftime("%H:%M")
found.append(f"{slot.strftime('%H:%M')} UTC (Asha {ist} IST, Ben {lon} London)")
slot = end
else:
slot += step
return "Common free slots:\n" + "\n".join(found) if found else "No common slot."
def propose(slots: str, attendees: str) -> str:
"""Draft a short proposal message. For review; external invites need approval."""
return client.models.generate_content(
model="gemini-2.5-flash",
contents=["Write a short, friendly message proposing these meeting times "
f"to {attendees}. Ask them to pick one. Times:\n{slots}"]).text
agent = LlmAgent(
name="scheduling_agent", model="gemini-2.5-flash",
instruction=("Call parse_request on the user's text, then free_slots for a "
"30-minute meeting, then propose using those slots. Report the "
"request type, the common free slots, and the proposal. Make clear "
"no invite is sent until a person approves."),
tools=[parse_request, free_slots, propose])
REQUEST = ("Set up a 30-minute call between Asha (Asia/Kolkata) and Ben "
"(Europe/London) on 2 July, sometime in their shared working hours.")
async def main():
runner = InMemoryRunner(agent=agent, app_name="scheduling")
session = await runner.session_service.create_session(app_name="scheduling", user_id="demo")
msg = types.Content(role="user", parts=[types.Part(text=REQUEST)])
async for event in runner.run_async(user_id="demo", session_id=session.id, new_message=msg):
if event.is_final_response():
print(event.content.parts[0].text)
if __name__ == "__main__":
asyncio.run(main()) The agent reports the request type, the common free slots (each shown in UTC and in both attendees' local zones, so the hour is unambiguous), and a drafted proposal asking them to pick one. The exact wording is model-generated, so it varies run to run; no invite is sent until a person approves.
A person sees one screen: the request, the common free slots shown in each attendee's own timezone, the focus and conflict checks, and a single book action, with external invites held for approval.
| Approach | Best for | Effort | Cost | Control |
|---|---|---|---|---|
| Custom agent (build in-house) | You want your own focus-time rules, booking policy, and approval flow, wired to your stack, with the data in-house | High | Build cost + API and model usage | Full |
| Scheduling SaaS (Calendly, Motion, Reclaim) | Booking links, auto-scheduling, and focus blocks out of the box, fast start, you accept their rules | Low | Subscription per seat | Medium |
| No-code (n8n or Make with a Calendar node) | A couple of simple booking rules, low volume, a quick pilot | Low | Cheap, but brittle once timezones and conflicts grow | Low |
Three things have to be right, and all three are deterministic. Timezones are handled explicitly: every time is stored and compared in UTC and rendered in each attendee's own zone, so a meeting is never an hour off. Booking is idempotent, keyed on the attendees, slot, and request, so a retry or a duplicated webhook can never create a second event. And any invite that leaves your domain waits for a person to approve, because an external calendar invite is not something to send on a guess.
Calendar entries are personal data, so they are handled in line with the DPDP Act 2023, whose core obligations phase in through 2027, and you can run the pipeline in your own cloud with access scoped to the calendars it needs. Whole calendars can be kept out of scope, the model sees only the request text and the slot labels it needs rather than your full calendar, and every read, proposal, and booking is written to an immutable audit log.
These are directional, not promises, and depend on how much your team schedules and across how many timezones. The point is the shape of the change: less time arranging, fewer hour-off mistakes, focus time kept, no double-bookings.
| Metric | Manual | Agent |
|---|---|---|
| Time spent arranging meetings | Long email threads to find one slot that works for everyone | Common free slots computed in seconds and proposed, so the thread is a single confirm |
| Timezone mistakes | Meetings booked an hour off, or in the wrong attendee's morning | Everything compared in UTC and shown in each attendee's own zone, so the hour is right |
| Focus time | Deep-work blocks get overwritten by whoever books first | Protected by rules, with a daily cap on meetings, so the calendar is not all fragments |
| Double-bookings | Two events land on the same slot when invites cross | Detected before booking and never created, because every write is idempotent |
Time saved depends on meeting volume and how many timezones you schedule across. Figures are ranges that vary by team, not guarantees.
Everything you need to know about the service and how it works. Can’t find an answer? Mail us at info@galific.com
Galific designs, builds, and runs scheduling agents like this on ADK, the Claude SDK, or LangGraph, wired into your Google Calendar or Outlook, with timezone-correct slots, protected focus time, and a person approving every external invite. Or explore the ready-made versions in our agent suite.