Agent Playbook, build tutorial

How to Build an AI Agent for Scheduling and Time Management

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.

Why scheduling eats the day

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.

Pick a framework: ADK, Claude SDK, or LangGraph

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.

The architecture

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.

1 to 2. Read calendars, parse free/busy in UTC, the request 3. Common free slots intersect in UTC, render local 4. Propose and read reply accept, decline, reschedule 5. Accepted? book if so 5. Book, send invites external waits for approval 6 to 7. Focus and conflict rules guard the slot 8. Run and monitor scheduled, audited Guardrails UTC throughout never double-book audit log accepted check rules cleared reschedule loop

Before you build: prerequisites

Inputs and access

The calendars to manage, with OAuth access to Google Calendar or Microsoft Graph, and each attendee's home timezone.

Systems to connect

A model API key (Gemini, Claude, or both), your calendar provider, the channel requests arrive on (email or chat), and a meeting-link provider.

Governance

Your focus-time and meeting-cap rules, which bookings are automatic versus approved, DPDP Act 2023 handling, and which calendars stay out of scope.

Setup: install and keys

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.

Shell (install and key)
# 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"

The stack, and why each piece

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.

Build it, step by step

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.

1

Connect the calendars and read free/busy in UTC

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.

Python (free/busy intake)
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.
2

Parse the scheduling request: who, duration, window, constraints, timezone

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}")
3

Compute common free slots across attendees, timezone-aware

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.

Python (deterministic)
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.
4

Propose times and handle the back-and-forth

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
5

Book the meeting and send invites; external invites on approval

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.

Python (idempotent booking)
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
6

Protect focus time with rules: block deep-work, cap meetings per day

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.

Python (focus rules)
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.
7

Detect and resolve conflicts and double-bookings

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.

Python (conflict check)
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"])
8

Assemble the run, with timezone, no-double-book, and approval guardrails

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

Put it together: run it end to end

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.

Python (full runnable file, ADK)
# 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.

The scheduling console

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.

Scheduling console REQUEST 30 min call Asha + Ben WindowWed 2 Jul, working hrs Typeone_to_one ZonesIST and London COMMON FREE SLOTS (UTC) 11:00 UTCAsha 16:30 IST, Ben 12:00 10:30 UTCAsha 16:00 IST, Ben 11:30 Focus blockrespected Meetings today3 of 5 Conflict checkno overlap PROPOSAL (for the attendees) Hi both, could we meet Wed2 Jul? Two options: 16:30 IST/ 12:00 London, or 16:00 /11:30. Reply with one. Internal books on accept. External invites need approval. Book on accept Edit

Build, buy, or no-code

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

Timezones, double-booking, and consent

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.

The numbers

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.

What makes it fail

  • !Comparing times in local zones instead of UTC, so meetings land an hour off.
  • !No idempotency key, so a retry or duplicated webhook books the same slot twice.
  • !Sending external invites automatically, so something reaches a partner with no check.
  • !Ignoring focus rules and the daily cap, so the calendar fills with fragments.
  • !Auto-resolving a genuine double-booking instead of sending it to a person.

A safe rollout

  1. Start by proposing slots only, no booking, on one team's internal meetings.
  2. Turn on automatic booking for internal meetings, with external invites still approved.
  3. Add focus-time protection and the daily meeting cap, tuned on real calendars.
  4. Expand to more teams and external scheduling, with a time-saved and conflict dashboard.

FAQs

General FAQs

Everything you need to know about the service and how it works. Can’t find an answer? Mail us at info@galific.com

  • Does it work with both Google Calendar and Outlook?
    Yes. It reads free/busy and creates events through the Google Calendar API and Microsoft Graph for Outlook, so an attendee on Gmail and one on Outlook are handled the same way. The availability engine works on busy blocks normalised to UTC, so it does not care which provider each calendar lives on, and a team split across both is one calculation, not two.
  • How does it get timezones right?
    By doing all the work in one timezone and only translating at the edges. Every busy block is converted to UTC the moment it is read, the free/busy intersection is computed entirely in UTC, and candidate slots are rendered back into each attendee's own zone only for display. UTC is the one timezone where overlap is unambiguous, so a meeting is never booked an hour off and never lands in someone's night, even across daylight-saving changes.
  • Will it book meetings without asking me?
    Internal meetings, yes, if you allow it, because booking an event on calendars inside your own domain is low-risk and reversible. 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. You decide where the line sits, and the approval step means nothing reaches a customer or partner without a human behind it.
  • How does it protect focus time?
    With rules you set over the calendar. Deep-work blocks are marked off limits so a meeting can never be booked over them, the day has a cap on how many meetings it will accept, and there is a minimum gap between them. Those rules are checked in the calendar owner's local zone, where a nine-to-eleven focus block means nine to eleven for them, so the calendar protects time rather than filling every open hour.
  • How does it detect and resolve conflicts?
    By comparing every event in UTC, where overlap is unambiguous. Two events that overlap are a conflict, and the agent surfaces them rather than hiding them: a genuine double-booking, two events of equal standing on the same slot, is flagged for a person, while a clear case such as an accepted invite over a tentative hold can be resolved by rule. The check runs before every booking and again on reconciliation.
  • How does it prevent double-bookings?
    Every booking carries an idempotency key built from the attendees, the slot, and the request id, and the agent checks that key before it writes. A retry, a duplicated webhook, or two replies arriving together can never create a second event, because the key is already booked. Combined with the UTC overlap check before each write, that is what makes it safe to point the agent at a live calendar.
  • Which framework should I use: ADK, the Claude SDK, or LangGraph?
    Whichever your team already runs, because the agent logic is identical in all three. The code tabs show the same parse, availability, propose, and book flow in Google ADK, the Anthropic SDK, and LangGraph. ADK and LangGraph are model-agnostic, so you can run Gemini or Claude under either; the timezone maths, the free/busy intersection, and the idempotent booking are the same Python regardless.
  • Should I build a custom agent or use a scheduling SaaS?
    Use a tool like Calendly, Motion, or Reclaim for booking links, auto-scheduling, and focus blocks with little setup, where you accept their rules. Build, or have it built, when you need your own focus-time policy, your booking and approval rules, custom conflict resolution, and the calendar data in-house. Teams often start with a SaaS and build a custom agent once their scheduling logic outgrows it. See the comparison table above.
  • What does it cost to run, and how fast is payback?
    Running cost is mostly model and API usage plus hosting, which is small against the time a team loses to scheduling back-and-forth across timezones. Because the agent computes common slots in seconds and proposes them in one message, the saving shows up immediately in shorter threads and fewer mis-booked meetings, so for a team that schedules across zones it tends to pay for itself quickly. Figures are directional, not promises.
  • Can it handle a meeting with many attendees?
    Yes. The free/busy intersection scales to any number of attendees: the agent reads every calendar, normalises each to UTC, and keeps only the slots where no one is busy. With more people the common windows naturally get scarcer, so the agent widens the search window or proposes the fewest-conflict options and flags who would have to move, rather than silently picking a slot that does not really work.
  • What should it not do?
    It should not send an external invite without approval, book over a focus block or a daily cap, or ever decide a timezone by guessing instead of computing it in UTC. It should not auto-resolve a genuine double-booking where both events have equal standing; that goes to a person. And whole categories, such as a leadership calendar or personal appointments, can be kept out of scope entirely, so the agent only ever touches the calendars you point it at.
  • Is my calendar data safe and private?
    Yes. 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. The model sees only the request text and the slot labels it needs to propose, not your whole calendar, and every read, proposal, and booking is written to an audit log.

Want this built and running in your stack?

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.