Build an AI Calendar Booking Agent

Build an AI agent that books Outlook meetings via OAuth2 and Microsoft Graph.

Introduction

30 Second Summary

Every time you click "Sign in with Microsoft" on an app, you're granting that app permission to do specific things on your behalf. That's OAuth2 in action.

In this project, you will build a command-line AI agent that authenticates as you to Microsoft Graph and books meetings on your real Outlook calendar. You'll tell it something like "Book a meeting tomorrow at 2pm" and it creates the event for you.

What You'll Build

A Python-based AI agent powered by a local LLM (Qwen3 via Ollama) that uses OAuth2 delegated permissions to manage your Outlook calendar through natural language commands.

By the end of this project, you'll have:

  • A registered OAuth2 application in Microsoft Entra ID with delegated calendar permissions.
  • A Python authentication module using MSAL that handles the full authorization code flow.
  • Custom LangChain tools that make authenticated API calls to Microsoft Graph to read and create calendar events.
  • An interactive agent wired to Qwen3 that decides when and how to call your calendar tools.
  • Secret Mission: Add a "find free slots" tool that checks your calendar for availability before booking.

Are there any prerequisites?

You'll need basic Python knowledge, a personal Microsoft account with an Outlook calendar, and VS Code installed on your computer. No prior experience with OAuth2, Microsoft Graph, or AI agents is required.

Before We Start

Before We Start...

Set Up Your Development Environment

Your AI agent needs a brain (a local LLM) and a body (Python code that connects to Microsoft Graph). In this step, you will install both pieces and create the project skeleton that ties them together.

By the end of this step, you will have Ollama running a Qwen3 model locally, a Python virtual environment with all required packages, and four starter files ready for code.

In this step, get ready to:
  • Install Ollama and pull the Qwen3 8B model.
  • Create your project folder and Python virtual environment.
  • Create the project file structure and configure the environment file.
Install Ollama and pull the Qwen3 model

Ollama is a tool that runs large language models on your own machine. Your agent will use the Qwen3 8B model, which has native support for tool calling.

  • Open ollama.com/download in your browser.
  • Download the installer for your operating system (.dmg for macOS, .exe for Windows).

macOS

  • Open the downloaded Ollama.dmg file.
  • Drag the Ollama icon into your Applications folder.
  • Open Applications and double-click Ollama to launch it.
  • If macOS shows a security prompt, click Open to confirm.
  • You should see the Ollama icon appear in your menu bar at the top of the screen.

Windows

  • Double-click the downloaded OllamaSetup.exe file.
  • Windows may show a User Account Control prompt asking if you want to allow changes. Click Yes.
  • Follow the installation wizard and click Install.
  • Ollama starts automatically after installation. You should see the Ollama icon in your system tray (bottom-right corner).

Now verify Ollama is working and pull the Qwen3 model.

  • Open a terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Verify Ollama is installed by running:
ollama --version

✔️ I see a version number

Ollama is installed and ready. Move on to pulling the model below.

ⓧ Command not found

The ollama command was not added to your PATH. Try closing your terminal and opening a new one. On macOS, make sure you launched the Ollama app from Applications first. On Windows, restart your computer if the issue persists.

Still not working?

help me troubleshoot why the ollama command is not found after installing Ollama

  • Pull the Qwen3 8B model by running this command:
ollama pull qwen3:8b

What does this command do?

This downloads the Qwen3 8B model (about 5.2 GB) to your machine. Qwen3 supports native tool calling, which means it can decide when to use the calendar tools you will build later.

The download may take a few minutes depending on your internet speed. You will see a progress bar in your terminal.

  • Once the download finishes, verify the model responds by running:
ollama run qwen3:8b "Say hello in one sentence"
  • You should see the model respond with a short greeting. Type /bye to exit the chat if it drops into interactive mode.

Model not responding or very slow?

Qwen3 8B needs at least 8 GB of RAM (16 GB recommended). If your machine is struggling, make sure no other heavy applications are open. The first run may be slower as the model loads into memory.

help me troubleshoot slow or unresponsive Ollama model performance

Create your project folder and Python virtual environment

A virtual environment keeps your project's packages separate from any other Python work on your machine. This prevents version conflicts between projects.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir calendar-agent
cd calendar-agent
  • Create a Python virtual environment by running:

macOS

python3 -m venv venv
  • Activate the virtual environment by running:
source venv/bin/activate

Windows

python -m venv venv
  • Activate the virtual environment by running:
venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

  • Install the required packages by running:
pip install langchain langchain-ollama msal requests python-dotenv

What are these packages?

  • langchain provides the agent framework that decides when to call your tools.
  • langchain-ollama connects LangChain to your local Ollama models.
  • msal is Microsoft's authentication library for handling OAuth2 sign-in.
  • requests makes HTTP calls to the Microsoft Graph API.
  • python-dotenv loads environment variables from your .env file.
  • Confirm the packages installed by running:
pip list | grep -i "langchain\|msal\|requests\|dotenv"

You should see all five packages listed with their version numbers.

Seeing a permission error or install failure?

Make sure you see (venv) in your terminal prompt. If not, you are installing packages globally instead of in your virtual environment. Re-run the activate command from above.

help me fix pip install errors in my Python virtual environment

Create the project file structure

Your agent will be split across three Python files: one for authentication, one for calendar tools, and one for the agent itself. You also need an environment file to store your app configuration.

  • Create the project files by running:

macOS

touch auth.py tools.py agent.py .env

Windows

New-Item auth.py, tools.py, agent.py, .env
  • Confirm the files exist by running:
ls

You should see auth.py, tools.py, agent.py, .env, and the venv folder in the output.

  • Open the calendar-agent folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the calendar-agent folder, and click Open.
  • In the Explorer sidebar, click on .env to open it.
  • Add the following placeholder line to your .env file:
CLIENT_ID=your-application-client-id-here

What is CLIENT_ID?

This is where you will paste your Microsoft app registration ID in the next step. For now it is a placeholder. Your .env file keeps sensitive configuration out of your source code.

  • Save the file by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you have saved your .env file.

ⓧ I'd like to double check the full code

Here is what your .env file should look like:

CLIENT_ID=your-application-client-id-here

Your other three files (auth.py, tools.py, agent.py) should be empty for now.

Your development environment is ready. Next up, you will register an OAuth2 app in Microsoft Entra ID so your agent has permission to access your calendar.

Register Your App in Microsoft Entra ID

Your local environment is ready with Ollama running, Qwen3 pulled, and all Python packages installed. Now it is time to give your agent permission to access your real Outlook calendar.

For your agent to read and create calendar events, Microsoft needs to know WHO is asking and WHAT they are allowed to do. You handle this by registering an application in Microsoft Entra ID (formerly Azure Active Directory). The registration gives your app a unique identity and defines which permissions it can request from users.

In this step, get ready to:
  • Register a new application called "Calendar Agent" in Microsoft Entra admin center.
  • Configure a redirect URI so MSAL can catch the OAuth2 callback.
  • Add the Calendars.ReadWrite delegated permission so your agent can manage your calendar.
Create a new app registration

An app registration tells Microsoft's identity platform that your application exists and defines what it is allowed to do. Without one, Microsoft Graph has no way to identify your agent.

  • Open your browser and go to entra.microsoft.com.
  • Sign in with your personal Microsoft account (the same one linked to your Outlook calendar).
  • In the left sidebar, expand Identity.
  • Expand Applications, then click App registrations.
  • Click New registration at the top of the page.

The registration form asks for a name and account type.

  • In the Name field, enter Calendar Agent.
  • Under Supported account types, select Personal Microsoft accounts only.
  • Leave the Redirect URI section blank for now. You will configure it in the next substep.
  • Click Register.

You land on the app's overview page. You should see your app name "Calendar Agent" at the top and several IDs listed below it.

  • Find the Application (client) ID value on the overview page.
  • Copy this ID. You will need it shortly.

What is the Application (client) ID?

This is your app's unique identifier in Microsoft's system. Every time your agent requests a token, it presents this ID so Microsoft knows which app is asking.

Unlike a client secret, this ID is not sensitive. It is safe to include in your .env file without encryption.

Configure the redirect URI

When you sign in through your browser, Microsoft needs to know where to send you back after authentication completes. This destination is called the redirect URI. For desktop apps, MSAL Python listens on http://localhost to catch the callback.

  • In the left sidebar of your app registration, click Authentication.
  • Click Add a platform.
  • In the panel that appears, select Mobile and desktop applications.

A list of suggested redirect URIs appears.

  • Check the box next to http://localhost.
  • Click Configure at the bottom.

You should see the Authentication page update with your new platform listed under Mobile and desktop applications.

Why http://localhost?

MSAL's acquire_token_interactive() method spins up a temporary listener on localhost. After you sign in via the browser, Microsoft redirects back to this listener with an authorization code. MSAL grabs the code and exchanges it for an access token, all without you needing to run a web server.

Add calendar permissions

Your app registration exists, but it has no permission to touch your calendar yet. You need to declare a delegated permission. Delegated means the app can only act on behalf of a signed-in user, never on its own.

  • In the left sidebar, click API permissions.
  • Click Add a permission.
  • In the panel that opens, select Microsoft Graph.
  • Select Delegated permissions.
  • In the search bar, type Calendars.ReadWrite.
  • Check the box next to Calendars.ReadWrite in the results.
  • Click Add permissions at the bottom.

You should now see two permissions listed on the API permissions page: User.Read (added by default) and Calendars.ReadWrite (the one you just added).

What are delegated permissions?

Delegated permissions define what your app is allowed to ASK the user for. They do not grant access automatically. The user must still consent during sign-in.

This is different from application permissions, which let an app access data without any user present. Your calendar agent uses delegated permissions because it should only ever act as YOU.

No admin consent needed

Because you selected "Personal Microsoft accounts only" and Calendars.ReadWrite is a user-consentable permission, you do not need an admin to approve anything. You grant consent yourself when you sign in for the first time.

Update your .env file with the client ID

Your Python code will need the Application (client) ID to authenticate. Store it in your .env file so your app can load it at runtime.

  • Go back to the Overview page of your app registration in the Entra admin center.
  • Copy the Application (client) ID value.
  • Open the .env file in your calendar-agent project folder.
  • Replace your-application-client-id-here with the client ID you just copied.
  • Save the file.

Your .env file should now look like this (with your actual ID in place of the placeholder):

CLIENT_ID=your-application-client-id-here

Can't find your Application (client) ID?

Make sure you are on the Overview page of your specific app registration, not the general App registrations list. Click your app name "Calendar Agent" in the list to open it.

The Application (client) ID is a UUID that looks like a1b2c3d4-e5f6-7890-abcd-ef1234567890. Do not confuse it with the Directory (tenant) ID or Object ID listed nearby.

Still stuck? Help me find my Application (client) ID in Microsoft Entra admin center.

Your app is now registered, configured with the right redirect URI, and has permission to access your calendar. Next up, you will write the Python code that uses this registration to sign you in and get an access token.

Build the OAuth2 Authentication Flow

Your app registration is ready in Microsoft Entra. It has a client ID, a redirect URI, and permission to access your calendar.

Now you need code that actually uses that registration to sign you in and get an access token. The access token is what proves to Microsoft Graph that you consented to let this app read and write your calendar.

In this step, get ready to:
  • Write an authentication module using MSAL Python that handles the OAuth2 authorization code flow.
  • Test the sign-in flow by running your module and signing in through your browser.
  • Understand how the OAuth2 authorization code flow works under the hood.
Write the MSAL authentication module

MSAL (Microsoft Authentication Library) handles the heavy lifting of OAuth2 for you. It opens your browser for sign-in, catches the callback on http://localhost, exchanges the authorization code for an access token, and caches that token so you don't have to sign in every time.

  • Open auth.py in VS Code and add the imports, configuration, and cache management functions by pasting this code:
import os
import json
import msal
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# Configuration for Microsoft Entra authentication
CLIENT_ID = os.getenv("CLIENT_ID")
AUTHORITY = "https://login.microsoftonline.com/consumers"
SCOPES = ["Calendars.ReadWrite"]
TOKEN_CACHE_FILE = "token_cache.json"

# Module-level singletons for the app and cache
_app = None
_cache = None


def _get_cache():
    """Load or create the token cache for persistent sign-in."""
    global _cache
    if _cache is None:
        _cache = msal.SerializableTokenCache()
        # Restore previously saved tokens if available
        if os.path.exists(TOKEN_CACHE_FILE):
            with open(TOKEN_CACHE_FILE, "r") as f:
                _cache.deserialize(f.read())
    return _cache


def _save_cache():
    """Persist the token cache to disk so sign-in survives between runs."""
    cache = _get_cache()
    if cache.has_state_changed:
        with open(TOKEN_CACHE_FILE, "w") as f:
            f.write(cache.serialize())


def _get_app():
    """Create the MSAL public client application (singleton)."""
    global _app
    if _app is None:
        _app = msal.PublicClientApplication(
            CLIENT_ID,
            authority=AUTHORITY,
            token_cache=_get_cache(),
        )
    return _app

What does this code do?

This sets up the MSAL infrastructure your agent needs to authenticate:

  • AUTHORITY points to the /consumers endpoint because you're using a personal Microsoft account.
  • SCOPES tells Microsoft exactly what your app wants permission to do. Calendars.ReadWrite matches the delegated permission you configured in Step 2.
  • _get_cache() and _save_cache() handle token persistence. Without them, you'd have to sign in every time you run the script.
  • _get_app() creates a PublicClientApplication. This is the correct MSAL class for desktop/CLI apps that don't store a client secret.
  • Below the _get_app() function, add the main authentication function and test block by pasting this code:
def get_access_token():
    """Get a valid access token, using cache or interactive sign-in."""
    app = _get_app()
    accounts = app.get_accounts()

    # Try silent authentication first (uses cached/refresh token)
    result = None
    if accounts:
        result = app.acquire_token_silent(SCOPES, account=accounts[0])

    # Fall back to interactive browser sign-in if no cached token
    if not result:
        print("No cached token found. Opening browser for sign-in...")
        result = app.acquire_token_interactive(scopes=SCOPES)

    # Save the cache so we don't need to sign in again next time
    _save_cache()

    if "access_token" in result:
        return result["access_token"]
    else:
        error = result.get("error_description", result.get("error", "Unknown error"))
        raise Exception(f"Authentication failed: {error}")


if __name__ == "__main__":
    token = get_access_token()
    print(f"Access token acquired: {token[:20]}...{token[-10:]}")
    print("Authentication successful!")

What does this code do?

get_access_token() is the function your tools will call whenever they need to talk to Microsoft Graph. It uses a two-step strategy:

  • acquire_token_silent() checks the cache for a valid token or uses a refresh token to get a new one silently.
  • acquire_token_interactive() opens your browser for a full sign-in flow. This only happens the first time, or when the refresh token expires.
  • The if __name__ == "__main__" block lets you test this module directly without running the full agent.
  • Save auth.py.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete auth.py file for comparison:

import os
import json
import msal
from dotenv import load_dotenv

load_dotenv()

CLIENT_ID = os.getenv("CLIENT_ID")
AUTHORITY = "https://login.microsoftonline.com/consumers"
SCOPES = ["Calendars.ReadWrite"]
TOKEN_CACHE_FILE = "token_cache.json"

_app = None
_cache = None


def _get_cache():
    global _cache
    if _cache is None:
        _cache = msal.SerializableTokenCache()
        if os.path.exists(TOKEN_CACHE_FILE):
            with open(TOKEN_CACHE_FILE, "r") as f:
                _cache.deserialize(f.read())
    return _cache


def _save_cache():
    cache = _get_cache()
    if cache.has_state_changed:
        with open(TOKEN_CACHE_FILE, "w") as f:
            f.write(cache.serialize())


def _get_app():
    global _app
    if _app is None:
        _app = msal.PublicClientApplication(
            CLIENT_ID,
            authority=AUTHORITY,
            token_cache=_get_cache(),
        )
    return _app


def get_access_token():
    app = _get_app()
    accounts = app.get_accounts()

    result = None
    if accounts:
        result = app.acquire_token_silent(SCOPES, account=accounts[0])

    if not result:
        print("No cached token found. Opening browser for sign-in...")
        result = app.acquire_token_interactive(scopes=SCOPES)

    _save_cache()

    if "access_token" in result:
        return result["access_token"]
    else:
        error = result.get("error_description", result.get("error", "Unknown error"))
        raise Exception(f"Authentication failed: {error}")


if __name__ == "__main__":
    token = get_access_token()
    print(f"Access token acquired: {token[:20]}...{token[-10:]}")
    print("Authentication successful!")
Test the sign-in flow

Your authentication module is complete. Time to run it and sign in with your real Microsoft account.

What will happen when you run this?

MSAL will open your default browser to Microsoft's sign-in page. After you sign in and consent to the calendar permission, your browser redirects to http://localhost. MSAL catches that redirect automatically and extracts the authorization code.

You may see a "This site can't be reached" page in your browser after consenting. That's normal. MSAL already captured what it needed from the URL.

  • Run the authentication module by entering this command in your terminal:
python auth.py
  • Your browser opens to the Microsoft sign-in page. Sign in with your personal Microsoft account.
  • You'll see a consent screen asking you to grant "Calendar Agent" permission to Read and write your calendars. Click Accept.
  • Switch back to your terminal. You should see output like this:
No cached token found. Opening browser for sign-in...
Access token acquired: eyJ0eXAiOiJKV1Qi...ccessToken
Authentication successful!
  • Confirm that a token_cache.json file appeared in your project folder by running:
ls

You should see token_cache.json listed alongside your other project files. This file stores your tokens so the next run won't need a browser sign-in.

Seeing an authentication error?

  • If you see AADSTS700016 or "Application not found", double check that the CLIENT_ID in your .env file matches the Application (client) ID from your app registration exactly.
  • If the browser never opens, make sure your virtual environment is activated and msal is installed (pip install msal).
  • If you see a redirect error about http://localhost, verify that you added the redirect URI under the **Mobile and desktop applications** platform in your app registration, not under **Web**.

Help me debug my MSAL authentication error

Understand what just happened

You just completed a full OAuth2 authorization code flow. This is the same pattern used by every app that says "Sign in with Microsoft" or "Sign in with Google."

How did the OAuth2 flow work?

Here's what happened behind the scenes when you ran auth.py:

  • MSAL redirected your browser to Microsoft's login page with your CLIENT_ID and requested Calendars.ReadWrite scope.
  • You signed in and clicked Accept, granting consent for your app to access your calendar.
  • Microsoft redirected your browser to http://localhost with a short-lived authorization code in the URL.
  • MSAL caught that redirect, extracted the authorization code, and exchanged it for an access token.
  • The access token is now your proof to Microsoft Graph: "This user consented to let this app read and write their calendar."

Why delegated permissions instead of application permissions?

Your app uses delegated permissions, which means it can only do what YOU can do. It acts on your behalf, not on its own. If you can read your calendar, the app can read your calendar. If you can't access someone else's calendar, neither can the app.

The alternative (application permissions) would let the app access any user's calendar without their consent. That requires admin approval and is meant for background services, not personal tools.

Your authentication layer is locked in. Next up, you'll build the calendar tools that use this access token to read and create events on your real Outlook calendar.

Create the Calendar Tools

Your authentication module handles the sign-in flow and delivers a valid access token. Now you need to give your agent the ability to actually do something with that token.

In this step, you will build two custom LangChain tools that call the Microsoft Graph API: one to read your calendar events and one to create new events. These tools are the "hands" your AI agent will use to interact with your real Outlook calendar.

In this step, get ready to:
  • Build a "get events" tool that fetches events from your Outlook calendar.
  • Build a "create event" tool that books new meetings on your calendar.
  • Test both tools manually against your real Microsoft account.
Build the "get events" tool

LangChain's @tool decorator turns a regular Python function into something an AI agent can discover and call. The function's type hints define the parameters the agent can pass in, and the docstring tells the agent what the tool does.

  • Open tools.py in VS Code and add the imports and base URL constant by pasting this code:
import requests
from datetime import datetime, timedelta
from langchain.tools import tool
from auth import get_access_token

GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"

What does this code do?

These imports bring in the libraries your tools need. requests makes HTTP calls to the Graph API. tool is the LangChain decorator that converts your function into an agent-callable tool.

GRAPH_BASE_URL stores the root URL for Microsoft Graph so you do not have to repeat it in every function.

You will not see a change yet. The next chunk uses these imports to build your first tool.

The first tool your agent needs is the ability to read your calendar. Microsoft Graph exposes a /me/calendarview endpoint that returns events within a date range.

  • Below the GRAPH_BASE_URL line, add the get_calendar_events function by pasting this code:

@tool
def get_calendar_events(start_date: str, end_date: str) -> str:
    """Get calendar events between two dates. Dates should be in ISO format like 2026-05-15. Returns a list of events with their subjects, start times, and end times."""
    token = get_access_token()
    headers = {"Authorization": f"Bearer {token}"}

    start_datetime = f"{start_date}T00:00:00"
    end_datetime = f"{end_date}T23:59:59"

    url = f"{GRAPH_BASE_URL}/me/calendarview"
    params = {
        "startDateTime": start_datetime,
        "endDateTime": end_datetime,
        "$orderby": "start/dateTime",
        "$select": "subject,start,end,location",
    }

    response = requests.get(url, headers=headers, params=params)

    if response.status_code != 200:
        return f"Error fetching events: {response.status_code} - {response.text}"

    events = response.json().get("value", [])

    if not events:
        return f"No events found between {start_date} and {end_date}."

    result_lines = [f"Events from {start_date} to {end_date}:"]
    for event in events:
        subject = event.get("subject", "No subject")
        start = event["start"]["dateTime"][:16].replace("T", " ")
        end = event["end"]["dateTime"][:16].replace("T", " ")
        location = event.get("location", {}).get("displayName", "")
        location_str = f" at {location}" if location else ""
        result_lines.append(f"- {subject}: {start} to {end}{location_str}")

    return "\n".join(result_lines)

What does this code do?

  • The @tool decorator registers this function so LangChain can expose it to your agent. The docstring becomes the tool's description that the LLM reads to decide when to use it.
  • get_access_token() fetches your cached OAuth2 token (or triggers a refresh) and attaches it as a Bearer token in the request header.
  • The function calls the /me/calendarview endpoint with start and end dates, then formats each event into a human-readable line.
  • The $select parameter limits the response to only the fields you need, keeping things fast and focused.
  • Save tools.py.

You will test this tool once the create tool is also in place.

Seeing an import error on save?

If your editor underlines from langchain.tools import tool in red, make sure your virtual environment is activated. Your terminal prompt should show the environment name in parentheses.

help me fix this LangChain import error in tools.py

Build the "create event" tool

Reading events is useful, but the real power comes from creating them. The Microsoft Graph POST /me/events endpoint lets you book new calendar events programmatically.

  • In tools.py, add the create_calendar_event function below the get_calendar_events function by pasting this code:

@tool
def create_calendar_event(
    subject: str, start_datetime: str, end_datetime: str, timezone: str = "UTC"
) -> str:
    """Create a new calendar event. start_datetime and end_datetime should be in ISO format like 2026-05-15T14:00:00. timezone defaults to UTC."""
    token = get_access_token()
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    }

    body = {
        "subject": subject,
        "start": {"dateTime": start_datetime, "timeZone": timezone},
        "end": {"dateTime": end_datetime, "timeZone": timezone},
        "body": {"contentType": "Text", "content": f"Created by Calendar Agent"},
    }

    url = f"{GRAPH_BASE_URL}/me/events"
    response = requests.post(url, headers=headers, json=body)

    if response.status_code == 201:
        event = response.json()
        return f"Event created successfully! Subject: {event['subject']}, Start: {start_datetime}, End: {end_datetime}"
    else:
        return f"Error creating event: {response.status_code} - {response.text}"

What does this code do?

  • This tool accepts a subject, start time, end time, and optional timezone. The LLM reads these type hints and knows exactly what arguments to provide.
  • The request body follows Microsoft Graph's event schema: it includes a subject, start/end objects with dateTime and timeZone, and a plain-text body.
  • A 201 status code means the event was created successfully. Any other status returns the error details for debugging.
  • Save tools.py.

Both tools are now written. You will test them in the next substep.

Why does each tool call get_access_token() separately?

Access tokens expire after about an hour. By calling get_access_token() at the start of each tool invocation, you guarantee you always have a fresh token. MSAL handles caching and refresh transparently, so repeated calls are instant unless the token actually needs refreshing.

✔️ Awesome, I've got everything!

Great. Double check that you have saved tools.py before moving on to testing.

ⓧ I'd like to double check the full code

Here is the complete tools.py file for comparison.

import requests
from datetime import datetime, timedelta
from langchain.tools import tool
from auth import get_access_token

GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0"


@tool
def get_calendar_events(start_date: str, end_date: str) -> str:
    """Get calendar events between two dates. Dates should be in ISO format like 2026-05-15. Returns a list of events with their subjects, start times, and end times."""
    token = get_access_token()
    headers = {"Authorization": f"Bearer {token}"}

    start_datetime = f"{start_date}T00:00:00"
    end_datetime = f"{end_date}T23:59:59"

    url = f"{GRAPH_BASE_URL}/me/calendarview"
    params = {
        "startDateTime": start_datetime,
        "endDateTime": end_datetime,
        "$orderby": "start/dateTime",
        "$select": "subject,start,end,location",
    }

    response = requests.get(url, headers=headers, params=params)

    if response.status_code != 200:
        return f"Error fetching events: {response.status_code} - {response.text}"

    events = response.json().get("value", [])

    if not events:
        return f"No events found between {start_date} and {end_date}."

    result_lines = [f"Events from {start_date} to {end_date}:"]
    for event in events:
        subject = event.get("subject", "No subject")
        start = event["start"]["dateTime"][:16].replace("T", " ")
        end = event["end"]["dateTime"][:16].replace("T", " ")
        location = event.get("location", {}).get("displayName", "")
        location_str = f" at {location}" if location else ""
        result_lines.append(f"- {subject}: {start} to {end}{location_str}")

    return "\n".join(result_lines)


@tool
def create_calendar_event(
    subject: str, start_datetime: str, end_datetime: str, timezone: str = "UTC"
) -> str:
    """Create a new calendar event. start_datetime and end_datetime should be in ISO format like 2026-05-15T14:00:00. timezone defaults to UTC."""
    token = get_access_token()
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json",
    }

    body = {
        "subject": subject,
        "start": {"dateTime": start_datetime, "timeZone": timezone},
        "end": {"dateTime": end_datetime, "timeZone": timezone},
        "body": {"contentType": "Text", "content": f"Created by Calendar Agent"},
    }

    url = f"{GRAPH_BASE_URL}/me/events"
    response = requests.post(url, headers=headers, json=body)

    if response.status_code == 201:
        event = response.json()
        return f"Event created successfully! Subject: {event['subject']}, Start: {start_datetime}, End: {end_datetime}"
    else:
        return f"Error creating event: {response.status_code} - {response.text}"
Test the tools manually

Before handing these tools to an AI agent, you should verify they work by calling them directly. LangChain tools expose an .invoke() method that lets you pass arguments as a dictionary.

  • Open a Python interactive session in your terminal by running:
python
  • Test the get events tool by running these commands in the Python prompt:
from tools import get_calendar_events, create_calendar_event

# Fetch today's events (adjust the date to today's actual date)
print(get_calendar_events.invoke({"start_date": "2026-05-10", "end_date": "2026-05-10"}))

What should I see?

You should see either a list of today's events formatted as bullet points, or a message saying "No events found between 2026-05-10 and 2026-05-10." Both are correct results.

  • Test the create event tool by running this command:
# Create a test event for tomorrow
print(create_calendar_event.invoke({"subject": "Test Event from Agent", "start_datetime": "2026-05-11T10:00:00", "end_datetime": "2026-05-11T10:30:00"}))
  • You should see a message like: Event created successfully! Subject: Test Event from Agent, Start: 2026-05-11T10:00:00, End: 2026-05-11T10:30:00.
  • Open your Outlook calendar in a browser or app and confirm you see the "Test Event from Agent" event on tomorrow's date.
  • Exit the Python session by typing:
exit()

Seeing a 401 Unauthorized error?

Your access token may have expired. Delete the token_cache.json file and try again. MSAL will open your browser for a fresh sign-in.

If you see a 403 Forbidden error, double check that your app registration has the Calendars.ReadWrite permission added in the Microsoft Entra admin center.

help me fix this Microsoft Graph authentication error when testing my calendar tools

Your tools are verified and working against your real calendar. Next up, you will wire these tools into a LangChain agent powered by Qwen3 so you can manage your calendar with natural language.

Build and Run Your AI Agent

In the last step, you built and tested your calendar tools. They can read events and create new ones on your real Outlook calendar.

Now you need something to decide WHEN to use those tools. That is where the LangChain agent comes in. You will wire up a local LLM (Qwen3 via Ollama) to your tools so you can type natural language commands and the agent figures out which tool to call.

In this step, get ready to:
  • Wire up the LangChain agent with a system prompt and your calendar tools.
  • Build an interactive chat loop that authenticates and runs the agent.
  • Test the agent end-to-end by querying and booking calendar events.
Wire up the LangChain agent

The agent needs three things: a model to do the thinking, tools to take actions, and a system prompt that explains its role.

  • In VS Code, open agent.py (it should be empty).
  • Add the imports and system prompt by copying the following code:
from langchain_ollama import ChatOllama
from langchain.agents import create_agent
from tools import get_calendar_events, create_calendar_event
from auth import get_access_token
from datetime import date

# System prompt tells the agent what it is and how to behave
SYSTEM_PROMPT = f"""You are a helpful calendar assistant. Today's date is {date.today().isoformat()}.

You can help users manage their Outlook calendar by:
1. Viewing their upcoming events
2. Creating new events

When creating events, always confirm the details with the user before proceeding.
When the user asks about their schedule, use the get_calendar_events tool with appropriate date ranges.
For date parameters, always use ISO format (e.g., 2026-05-15 for dates, 2026-05-15T14:00:00 for datetimes).
If the user says "tomorrow", calculate the actual date based on today's date.
"""

What does this code do?

The imports pull in ChatOllama (connects to your local Qwen3 model), create_agent (the LangChain function that wires a model to tools), and your two calendar tools from the previous step.

The SYSTEM_PROMPT is an f-string that injects today's date dynamically. This ensures the agent always knows what "tomorrow" or "next week" means. The prompt also instructs the agent to confirm details before booking, preventing accidental events.

  • Save agent.py.

You will not see a change yet. The next chunk adds the main() function that actually runs the agent.

Build the interactive chat loop

The agent needs an entry point that authenticates you, creates the agent instance, and loops to read your messages.

  • In agent.py, add the following code below the SYSTEM_PROMPT string:

def main():
    print("=" * 50)
    print("  Calendar Booking Agent")
    print("=" * 50)
    print("\nAuthenticating with Microsoft Graph...")

    # Authenticate once at startup using cached token or browser sign-in
    try:
        get_access_token()
        print("Authentication successful!\n")
    except Exception as e:
        print(f"Authentication failed: {e}")
        return

    # Create the Ollama model instance pointing to your local Qwen3
    model = ChatOllama(model="qwen3:8b")

    # Wire the model, tools, and prompt into a LangChain agent
    agent = create_agent(
        model,
        tools=[get_calendar_events, create_calendar_event],
        prompt=SYSTEM_PROMPT,
    )

    print("Agent ready! Ask me to check your calendar or book meetings.")
    print("Type 'quit' or 'exit' to stop.\n")

    # Interactive loop: read input, invoke agent, print response
    while True:
        user_input = input("You: ").strip()
        if not user_input:
            continue
        if user_input.lower() in ["quit", "exit"]:
            print("Goodbye!")
            break

        result = agent.invoke(
            {"messages": [{"role": "user", "content": user_input}]}
        )

        response = result["messages"][-1].content
        print(f"\nAgent: {response}\n")


if __name__ == "__main__":
    main()

What does this code do?

  • get_access_token() runs at startup to ensure you have a valid token. If your cached token expired, it opens your browser for re-authentication.
  • ChatOllama(model="qwen3:8b") creates a chat model instance connected to your local Ollama server running Qwen3.
  • create_agent() combines the model, your two tools, and the system prompt into a compiled agent graph. The agent decides when to call tools based on the user's message.
  • The while True loop reads user input, passes it as a message to the agent, and prints the last message in the response. Typing quit or exit breaks the loop.

Why use create_agent instead of calling Ollama directly?

Calling Ollama directly would mean you have to manually parse tool calls, feed results back, and handle multi-turn reasoning yourself. create_agent handles all of this automatically. The agent inspects tool schemas, decides which tool to call, executes it, reads the result, and continues reasoning until it has a final answer.

  • Save agent.py.

Seeing an import error?

  • Make sure your virtual environment is activated. You should see (venv) or similar at the start of your terminal prompt.
  • Verify langchain-ollama is installed by running pip list | grep langchain and checking for langchain-ollama in the output.
  • If from langchain.agents import create_agent fails, make sure you have langchain version 1.2.10 or later installed.

Help me fix an import error in my LangChain calendar agent

✔️ Awesome, I've got everything!

Great. Double check you have saved agent.py and that it contains both the SYSTEM_PROMPT and the main() function.

ⓧ I'd like to double check the full code

from langchain_ollama import ChatOllama
from langchain.agents import create_agent
from tools import get_calendar_events, create_calendar_event
from auth import get_access_token
from datetime import date

SYSTEM_PROMPT = f"""You are a helpful calendar assistant. Today's date is {date.today().isoformat()}.

You can help users manage their Outlook calendar by:
1. Viewing their upcoming events
2. Creating new events

When creating events, always confirm the details with the user before proceeding.
When the user asks about their schedule, use the get_calendar_events tool with appropriate date ranges.
For date parameters, always use ISO format (e.g., 2026-05-15 for dates, 2026-05-15T14:00:00 for datetimes).
If the user says "tomorrow", calculate the actual date based on today's date.
"""


def main():
    print("=" * 50)
    print("  Calendar Booking Agent")
    print("=" * 50)
    print("\nAuthenticating with Microsoft Graph...")

    try:
        get_access_token()
        print("Authentication successful!\n")
    except Exception as e:
        print(f"Authentication failed: {e}")
        return

    model = ChatOllama(model="qwen3:8b")

    agent = create_agent(
        model,
        tools=[get_calendar_events, create_calendar_event],
        prompt=SYSTEM_PROMPT,
    )

    print("Agent ready! Ask me to check your calendar or book meetings.")
    print("Type 'quit' or 'exit' to stop.\n")

    while True:
        user_input = input("You: ").strip()
        if not user_input:
            continue
        if user_input.lower() in ["quit", "exit"]:
            print("Goodbye!")
            break

        result = agent.invoke(
            {"messages": [{"role": "user", "content": user_input}]}
        )

        response = result["messages"][-1].content
        print(f"\nAgent: {response}\n")


if __name__ == "__main__":
    main()
Test your agent end-to-end

Time to see your agent in action. You will ask it to read your calendar and then book a real meeting.

  • Make sure Ollama is running in the background. If it is not, open a new terminal and run:
ollama serve
  • In your project terminal (with your virtual environment active), start the agent by running:
python agent.py

You should see the authentication message followed by "Agent ready!" and a You: prompt waiting for your input.

What should I see?

==================================================
  Calendar Booking Agent
==================================================

Authenticating with Microsoft Graph...
Authentication successful!

Agent ready! Ask me to check your calendar or book meetings.
Type 'quit' or 'exit' to stop.

You:
  • Ask the agent about your schedule by typing:
What meetings do I have this week?

The agent calls the get_calendar_events tool with this week's date range and returns a list of your real Outlook events.

  • Now ask the agent to book a meeting by typing:
Book a 30-minute meeting tomorrow at 3pm called Project Review

The agent should confirm the details with you before creating the event. Once you approve, it calls the create_calendar_event tool and returns a confirmation message.

  • Open your Outlook calendar in your browser to verify the "Project Review" event appears at 3pm tomorrow.
  • Type exit in the terminal to stop the agent.

Agent not calling tools or giving strange output?

  • Qwen3 runs in "thinking mode" by default, which can interfere with tool calling. If you see reasoning tokens in the output, try restarting and asking a simpler question like "What is today's date?" first.
  • Make sure Ollama is running. If you see a connection error, open a separate terminal and run ollama serve.
  • If the agent responds but never calls tools, check that your SYSTEM_PROMPT includes the instruction about using get_calendar_events with date ranges.
  • If authentication fails with "token expired", the agent will automatically open your browser to re-authenticate. Allow the sign-in to complete.

My calendar agent is not calling tools correctly

Your AI calendar agent is fully working. It authenticates as you, reads your schedule, and books meetings on your real Outlook calendar. Next, the Secret Mission challenges you to make the agent even smarter by checking for conflicts before booking.

Secret mission

Add a Free Slots Tool That Checks Availability Before Booking

Your agent can book meetings, but it has no idea if you're already busy. What if it double-books you? In this secret mission, you'll add a third tool that checks your calendar for availability before suggesting a time to book.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with Ollama and uses the free tier of Microsoft Graph API. There are no ongoing costs to worry about. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Ollama with the Qwen3 8B model (approximately 5.2GB on disk).
  • Python virtual environment and installed packages in the calendar-agent folder.
  • Azure app registration in Microsoft Entra ID (Calendar Agent).
  • Token cache file (token_cache.json) storing your Microsoft sign-in session.
  • Test calendar events created on your Outlook calendar.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with your calendar agent.

  • Ollama continues running in the background. Your Qwen3 model stays downloaded and ready to use.
  • Your app registration stays active in Microsoft Entra ID.
  • Your token cache keeps your sign-in session alive so you do not have to re-authenticate each time.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping your files and app registration intact.

  • Stop Ollama by running:
ollama stop qwen3:8b
  • Delete your token cache to revoke the stored session. You will sign in again next time you run the agent.
rm token_cache.json
  • Keep your calendar-agent folder and app registration as they are. When you are ready to resume, just activate your virtual environment and run agent.py again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This removes local files, the Ollama model, and your Azure app registration.

  • Delete the project folder from your Desktop.
rm -rf ~/Desktop/calendar-agent
  • Remove the Qwen3 model from Ollama to free up approximately 5.2GB of disk space.
ollama rm qwen3:8b
  • Delete your app registration in Microsoft Entra ID:
  • Go to entra.microsoft.com.
  • Navigate to Identity > Applications > App registrations.
  • Click on Calendar Agent.
  • Click Delete and confirm.
  • Delete any test events from your Outlook calendar that the agent created during testing.

Nice Work!

Nice Work!

Nice work! You just built an AI agent that authenticates to Microsoft Graph using OAuth2 delegated permissions and manages your real Outlook calendar.

Here's what you accomplished:

  • Registered an OAuth2 application in Microsoft Entra ID and implemented the authorization code flow with MSAL Python to securely authenticate on behalf of a user.
  • Built custom LangChain tools that make authenticated API calls to Microsoft Graph to read and create calendar events.
  • Wired a local LLM (Qwen3 via Ollama) into an agent that decides when and how to call your tools based on natural language input.
  • Added a find free slots tool so your agent checks availability before booking meetings.

Ready to quiz yourself?