Build a Canvas Assignment Agent
Build a Python AI agent that texts you daily Canvas assignment summaries.
Introduction
30 Second Summary
You know that feeling when you forget an assignment is due until the night before? Or when you check your calendar five times a day just to make sure you haven't missed anything? What if your phone just told you what's due every morning before you even got out of bed?
In this project, you will build a Python AI agent that wakes up every morning, pulls your upcoming assignments from Google Calendar, uses a local AI model to write a concise summary, and texts it straight to your phone.
What You'll Build
A Python script that acts as your personal assignment assistant, connecting the Google Calendar API to a local LLM and delivering SMS summaries on autopilot.
By the end of this project, you'll have:
- A working sense-think-act agent loop that fetches your assignment due dates from Google Calendar, summarizes them with a local AI, and sends the result as a text message.
- A local LLM integration using Ollama that composes natural-language summaries tailored for SMS.
- A cron job that runs the agent every morning without you lifting a finger.
- Secret Mission: Add priority sorting so the most urgent assignments appear first, with time-remaining indicators.
Are there any prerequisites?
You should have some Python experience and a Mac running macOS 14 Sonoma or newer. You will also need a Google account (for both Calendar and Gmail), 2-factor authentication enabled on Gmail, and an AT&T phone number for receiving texts.
Set Up Your Tools
Your agent needs three things to function: a local AI model to write summaries, access to your Google Calendar assignments, and a way to send text messages.
In this step, you'll install Ollama (a tool that runs AI models on your Mac), set up Google Calendar API credentials, and create a Gmail app password.
In this step, get ready to:
- Install Ollama and pull the AI model your agent will use.
- Set up Google Calendar API credentials to read your assignments.
- Create a Gmail app password to send text messages.
Install Ollama and pull a model
Ollama lets you run large language models entirely on your Mac. No cloud API keys, no rate limits, no internet required once the model is downloaded.
- Press Cmd+Space to open Spotlight Search.
- Type Terminal and press Enter to open it.
- Install Ollama by running this command:
curl -fsSL https://ollama.com/install.sh | sh
What does this command do?
This downloads and runs Ollama's official install script. It installs the ollama command-line tool and sets it up to run as a background service on your Mac.
Ollama registers itself as a login item, so it starts automatically when you restart your Mac.
✔️ I see a success message
Ollama is installed. Move on to pulling the model below.
ⓧ I see a permission error
The install script may need admin permissions. Try running it with sudo:
curl -fsSL https://ollama.com/install.sh | sudo sh
Enter your Mac password when prompted (you will not see characters as you type).
ⓧ Command not found or network error
Make sure you are connected to the internet. If curl is not found, open the App Store and install Xcode Command Line Tools first.
Now you need to download the AI model your agent will use. The llama3.2:3b model is small (around 2 GB) and fast on Apple Silicon.
- Pull the model by running this command:
ollama pull llama3.2:3b
What does this command do?
This downloads the llama3.2:3b model files to your Mac's ~/.ollama directory. The download is about 2 GB, so it may take a few minutes depending on your internet speed.
- Verify the model works by running this command:
ollama run llama3.2:3b "Say hello in one sentence"
You should see a short, friendly response from the model in your terminal.
Not seeing a response?
If you see an error about Ollama not running, the background service may not have started yet. Try running ollama serve in a separate terminal window, then retry the command above.
If the pull command failed partway through, run ollama pull llama3.2:3b again. It resumes where it left off.
help me troubleshoot why Ollama is not responding to my prompt
Set up Google Calendar API credentials
Your agent needs permission to read events from your Google Calendar. Google uses OAuth 2.0 for this. You'll create a project in Google Cloud Console, enable the Calendar API, and download a credentials file.
- Open your browser and go to https://console.cloud.google.com/.
- Sign in with the Google account that has your assignment calendar.
- Click Select a project at the top of the page, then click New Project.
- Name it Canvas Agent and click Create.
- Once the project is created, make sure it is selected in the top dropdown.
- In the search bar at the top, type Google Calendar API and select the result.
- Click Enable to turn on the Calendar API for your project.
- In the left sidebar, go to Google Auth platform, then Clients.
- If prompted to configure the OAuth consent screen, click Get Started, enter an app name and your email, set the audience to External, agree to the data policy, and click Create.
- Click Create Client.
- Select Desktop app as the application type.
- Name it Assignment Agent and click Create.
- Click Download JSON to download the credentials file. Keep this file handy. You will move it into your project folder in the next step.
What is OAuth 2.0?
OAuth 2.0 is a protocol that lets your script access your Google Calendar without needing your Google password. Instead, the first time you run the script, a browser window opens and asks you to grant permission. After that, a token file is saved locally so you don't have to authorize again.
The credentials.json file you downloaded identifies your app to Google. Keep it private.
Create a Gmail app password
Your agent sends text messages by emailing a special carrier address. Gmail requires an app password for scripts to send email on your behalf.
Why do I need an app password?
Google does not allow scripts to log in with your regular Gmail password. Instead, you generate a 16-character app password that your script uses to authenticate with Gmail's SMTP server.
Your Google account must have 2-step verification (2FA) enabled for app passwords to be available.
- Open your browser and go to https://myaccount.google.com/apppasswords.
- Sign in with your Gmail account if prompted.
- Select Mail as the app.
- Select Mac as the device.
- Click Generate.
- Copy the 16-character app password that appears (the spaces are just for readability, ignore them).
- Paste the password somewhere safe. You'll use it in the next step.
Don't see the app passwords page?
App passwords are only available if you have 2-step verification enabled on your Google account. Go to https://myaccount.google.com/security and enable 2-Step Verification first, then return to the app passwords page.
help me enable 2FA on my Google account so I can create an app password
You have all three tools and credentials ready. Next up, you'll write the Python code that uses your Google Calendar credentials to fetch your real assignments.
Build the Canvas Integration
Build the Calendar Integration
Your tools are installed and your credentials are ready. Now it's time to build the module that actually talks to Google Calendar.
In this step, you'll create a Python script that connects to the Google Calendar API, authenticates with your credentials, and pulls back every upcoming event that represents an assignment due date. By the end, you'll have a working integration that fetches real data from your calendar.
In this step, get ready to:
- Create a project folder with a virtual environment and install dependencies.
- Write a Calendar API module that authenticates and fetches upcoming events.
- Test the module and confirm it returns your real assignment data.
Set up the project structure
Before writing any code, you need a dedicated folder, an isolated Python environment, and a credentials file that keeps your secrets out of source code.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir canvas-agent
cd canvas-agent
- Create a Python virtual environment and activate it by running:
python3 -m venv venv
source venv/bin/activate
What is a virtual environment?
A virtual environment is an isolated Python installation inside your project folder. Packages you install here won't conflict with other Python projects on your Mac.
You'll know it's active when you see (venv) at the start of your terminal prompt.
- Install the packages your agent needs by running:
pip install ollama google-api-python-client google-auth-httplib2 google-auth-oauthlib python-dotenv
What do these packages do?
- google-api-python-client is Google's official library for calling their APIs, including Calendar.
- google-auth-httplib2 and google-auth-oauthlib handle the OAuth 2.0 authentication flow (the browser popup that asks for permission).
- python-dotenv loads credentials from a .env file so you never hardcode secrets.
- ollama is the Python library for talking to your local LLM (you'll use this in the next step).
You should see a success message listing all installed packages.
Seeing a 'pip not found' error?
Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If not, run source venv/bin/activate again.
help me fix my pip install error in my canvas-agent virtual environment
Now open the project in Cursor so you can start writing code.
- Press Cmd+Space to open Spotlight search.
- Type Cursor and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the canvas-agent folder on your Desktop, then click Open.
You should see canvas-agent in the Cursor sidebar with the venv folder visible.
Now move your credentials.json file (the one you downloaded from Google Cloud Console) into the canvas-agent folder on your Desktop.
- In the Cursor sidebar, right-click the empty area and select New File.
- Name it .env and press Enter.
- Paste the following into .env:
GMAIL_ADDRESS=your-email@gmail.com
GMAIL_APP_PASSWORD=your-app-password-here
PHONE_NUMBER=your-10-digit-number-here
CARRIER_GATEWAY=txt.att.net
What do I replace?
- Replace your-email@gmail.com with your actual Gmail address.
- Replace your-app-password-here with your 16-character Gmail app password from Step 1.
- Replace your-10-digit-number-here with your AT&T phone number (digits only, no dashes).
- Save .env with Cmd+S.
File not showing in the sidebar?
Files starting with a dot are hidden by default on macOS. Cursor shows them in the sidebar, but if you don't see .env, make sure you typed the filename starting with a dot.
help me create a .env file in Cursor
Write the Calendar API module
This module handles all communication with the Google Calendar API. It authenticates with your credentials, fetches upcoming events, and filters them by date.
- Create a new file in the Cursor sidebar called calendar_client.py.
- Add the imports and authentication logic by pasting this code:
import os
from datetime import datetime, timezone, timedelta
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
# Only need read access to your calendar
SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
def get_calendar_service():
"""Authenticate and return a Calendar API service object."""
creds = None
# token.json stores your access/refresh tokens after first login
if os.path.exists("token.json"):
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
# If no valid credentials, start the OAuth flow
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(
"credentials.json", SCOPES
)
creds = flow.run_local_server(port=0)
# Save the token for future runs
with open("token.json", "w") as token:
token.write(creds.to_json())
return build("calendar", "v3", credentials=creds)
What does this code do?
- SCOPES tells Google you only need read-only access to the calendar. This is the minimum permission needed.
- get_calendar_service() checks for a saved token.json first. If found, it reuses the saved credentials without opening a browser.
- If no token exists (first run), it reads credentials.json and opens a browser window for you to grant permission. After that, it saves the token so you never have to authorize again.
Now add the function that fetches events from your calendar within a given time window.
- Below get_calendar_service(), add the function that fetches upcoming events by pasting this code:
def get_upcoming_events(days_ahead=1):
"""Fetch events from all your Google Calendars within the specified time window."""
service = get_calendar_service()
now = datetime.now(timezone.utc)
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=days_ahead)
# Format times for the Calendar API
time_min = start.isoformat()
time_max = end.isoformat()
# Get all calendars you have access to (includes subscribed Canvas calendars)
calendars_result = service.calendarList().list().execute()
calendars = calendars_result.get("items", [])
assignments = []
for calendar in calendars:
cal_id = calendar["id"]
cal_name = calendar.get("summary", "Calendar")
# Fetch events from this calendar within the time window
events_result = service.events().list(
calendarId=cal_id,
timeMin=time_min,
timeMax=time_max,
singleEvents=True,
orderBy="startTime"
).execute()
for event in events_result.get("items", []):
# Handle both dateTime and all-day date formats
start_info = event["start"].get("dateTime", event["start"].get("date"))
if "T" in start_info:
due_date = datetime.fromisoformat(start_info)
else:
due_date = datetime.fromisoformat(start_info + "T23:59:00+00:00")
assignments.append({
"name": event.get("summary", "Untitled Event"),
"course": cal_name,
"due_at": due_date,
"html_url": event.get("htmlLink", "")
})
# Sort by due date so the soonest deadlines come first
assignments.sort(key=lambda x: x["due_at"])
return assignments
What does this code do?
This function loops through every calendar you have access to in Google Calendar, including any subscribed Canvas calendar feeds. For each calendar, it calls events().list() with a time window so Google only returns events happening between now and your cutoff date.
Each event is converted into a dictionary with name, course (the calendar it came from), due_at, and html_url. The results are sorted by date so the soonest deadlines come first.
Finally, add two convenience functions that define the time windows for "today" and "this week."
- Below get_upcoming_events(), add these two functions:
def get_todays_assignments():
"""Fetch events due today."""
return get_upcoming_events(days_ahead=1)
def get_weeks_assignments():
"""Fetch events due in the next 7 days."""
return get_upcoming_events(days_ahead=7)
What does this code do?
get_todays_assignments() fetches events from midnight today to midnight tomorrow. get_weeks_assignments() fetches events from midnight today to 7 days out.
Both are thin wrappers around get_upcoming_events(). Your agent will call the daily version most days and the weekly version on Mondays.
- Save calendar_client.py with Cmd+S.
Test the integration
Your Calendar module needs a way to run standalone so you can verify it pulls real data before wiring it into the agent.
- At the bottom of calendar_client.py, add this test block:
if __name__ == "__main__":
print("Upcoming events today:")
for a in get_todays_assignments():
print(f" - {a['name']} ({a['course']}) due {a['due_at']}")
print("\nUpcoming events this week:")
for a in get_weeks_assignments():
print(f" - {a['name']} ({a['course']}) due {a['due_at']}")
What does this code do?
The if __name__ == "__main__": block only runs when you execute the file directly (not when another script imports it). It prints your active courses and today's assignments so you can quickly verify the API connection works.
- Save calendar_client.py with Cmd+S.
- Run the module in your terminal by running:
python calendar_client.py
The first time you run this, a browser window opens asking you to grant your app access to Google Calendar. Click Allow. After that, you should see your upcoming events listed in the terminal.
Seeing an authorization or connection error?
- Make sure credentials.json is in your canvas-agent project folder (the same folder where calendar_client.py lives).
- If the browser window didn't open, check that port 0 is not blocked. Try running the script again.
- If you see a "redirect_uri_mismatch" error, go back to Google Cloud Console and make sure your OAuth client type is set to Desktop app.
- Confirm your virtual environment is active (you should see (venv) in your prompt).
help me debug my Google Calendar API authorization error in calendar_client.py
✔️ Awesome, I've got everything!
Great. Double check you've saved calendar_client.py and .env.
ⓧ I'd like to double check the full code
Here is the full .env file:
GMAIL_ADDRESS=your-email@gmail.com
GMAIL_APP_PASSWORD=your-app-password-here
PHONE_NUMBER=your-10-digit-number-here
CARRIER_GATEWAY=txt.att.net
Here is the full calendar_client.py file:
import os
from datetime import datetime, timezone, timedelta
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
# Only need read access to your calendar
SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
def get_calendar_service():
"""Authenticate and return a Calendar API service object."""
creds = None
# token.json stores your access/refresh tokens after first login
if os.path.exists("token.json"):
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
# If no valid credentials, start the OAuth flow
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(
"credentials.json", SCOPES
)
creds = flow.run_local_server(port=0)
# Save the token for future runs
with open("token.json", "w") as token:
token.write(creds.to_json())
return build("calendar", "v3", credentials=creds)
def get_upcoming_events(days_ahead=1):
"""Fetch events from all your Google Calendars within the specified time window."""
service = get_calendar_service()
now = datetime.now(timezone.utc)
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
end = start + timedelta(days=days_ahead)
time_min = start.isoformat()
time_max = end.isoformat()
# Get all calendars you have access to (includes subscribed Canvas calendars)
calendars_result = service.calendarList().list().execute()
calendars = calendars_result.get("items", [])
assignments = []
for calendar in calendars:
cal_id = calendar["id"]
cal_name = calendar.get("summary", "Calendar")
events_result = service.events().list(
calendarId=cal_id,
timeMin=time_min,
timeMax=time_max,
singleEvents=True,
orderBy="startTime"
).execute()
for event in events_result.get("items", []):
start_info = event["start"].get("dateTime", event["start"].get("date"))
if "T" in start_info:
due_date = datetime.fromisoformat(start_info)
else:
due_date = datetime.fromisoformat(start_info + "T23:59:00+00:00")
assignments.append({
"name": event.get("summary", "Untitled Event"),
"course": cal_name,
"due_at": due_date,
"html_url": event.get("htmlLink", "")
})
assignments.sort(key=lambda x: x["due_at"])
return assignments
def get_todays_assignments():
"""Fetch events due today."""
return get_upcoming_events(days_ahead=1)
def get_weeks_assignments():
"""Fetch events due in the next 7 days."""
return get_upcoming_events(days_ahead=7)
if __name__ == "__main__":
print("Upcoming events today:")
for a in get_todays_assignments():
print(f" - {a['name']} ({a['course']}) due {a['due_at']}")
print("\nUpcoming events this week:")
for a in get_weeks_assignments():
print(f" - {a['name']} ({a['course']}) due {a['due_at']}")
Your Google Calendar integration is live and pulling real data. Next up, you'll add an AI brain that turns those raw events into a concise text-message summary.
Add the AI Brain
Your Calendar integration can now sense what's coming up. It pulls events from Google Calendar and filters them by date. But raw data isn't useful on its own.
You need a brain that can read that data and turn it into a short, friendly text message. In this step, you'll connect Ollama's local LLM to your Calendar data and wire everything together in a main agent script that orchestrates the full sense-think-act loop.
In this step, get ready to:
- Write a summarizer module that uses Ollama to generate concise SMS summaries.
- Create the main agent script that orchestrates the sense-think-act loop.
- Test the LLM output to confirm it produces clean, readable summaries.
Write the LLM summarizer module
The summarizer takes your list of assignments and crafts a prompt that tells the LLM exactly what kind of output you want. It needs to handle two modes: a short daily summary and a longer weekly summary for Mondays.
- In Cursor, create a new file called summarizer.py in your project folder.
- Add the imports and the build_prompt() function by pasting this code:
from ollama import chat
from datetime import datetime, timezone
def build_prompt(assignments, is_monday=False):
# Return early if there's nothing to summarize
if not assignments:
return None
now = datetime.now(timezone.utc)
assignment_lines = []
for a in assignments:
# Calculate how many hours until this assignment is due
hours_left = (a["due_at"] - now).total_seconds() / 3600
time_info = f"{hours_left:.0f} hours from now"
if hours_left < 0:
time_info = "PAST DUE"
elif hours_left < 24:
time_info = f"in {hours_left:.0f}h"
day_name = a["due_at"].strftime("%A")
assignment_lines.append(
f"- {a['name']} ({a['course']}) - due {day_name} {a['due_at'].strftime('%I:%M %p')} ({time_info})"
)
assignments_text = "\n".join(assignment_lines)
# Use different prompts for Monday (weekly) vs other days (daily)
if is_monday:
prompt = (
f"You are a helpful assignment assistant. Summarize these assignments due this week "
f"into a concise text message. Group by day. Keep it under 500 characters. "
f"Be casual and friendly. Here are the assignments:\n\n{assignments_text}"
)
else:
prompt = (
f"You are a helpful assignment assistant. Summarize these assignments due today "
f"into a concise text message. Keep it under 300 characters. "
f"Be casual and friendly. Here are the assignments:\n\n{assignments_text}"
)
return prompt
What does this code do?
The build_prompt() function transforms your raw assignment data into a structured prompt for the LLM.
- It calculates how many hours remain until each deadline, giving the LLM time context.
- It formats each assignment as a readable line with the course name, day, time, and urgency.
- For Mondays it asks for a 500-character weekly summary grouped by day. Other days get a tighter 300-character daily summary.
- If there are no assignments, it returns None so the caller can handle the empty case separately.
- Save summarizer.py.
- Now add the generate_summary() function below build_prompt() in the same file:
def generate_summary(assignments, is_monday=False):
prompt = build_prompt(assignments, is_monday)
# Handle the case where no assignments are due
if prompt is None:
return "Nothing due today! You're all caught up."
# Send the prompt to the local Ollama model
response = chat(
model="llama3.2:3b",
messages=[{"role": "user", "content": prompt}]
)
return response.message.content
What does this code do?
The generate_summary() function is the public interface for the summarizer module. It builds the prompt, sends it to llama3.2:3b via the Ollama Python library, and returns the model's text response.
If no assignments are due, it short-circuits and returns a friendly "all caught up" message without calling the LLM at all.
- Save summarizer.py.
Seeing a red underline on the ollama import?
Make sure your virtual environment is active. You should see the environment name in parentheses at the start of your terminal prompt. If not, activate it again with source venv/bin/activate.
Help me fix this
✔️ Awesome, I've got everything!
Great. Double check you've saved summarizer.py before moving on.
ⓧ I'd like to double check the full code
from ollama import chat
from datetime import datetime, timezone
def build_prompt(assignments, is_monday=False):
if not assignments:
return None
now = datetime.now(timezone.utc)
assignment_lines = []
for a in assignments:
hours_left = (a["due_at"] - now).total_seconds() / 3600
time_info = f"{hours_left:.0f} hours from now"
if hours_left < 0:
time_info = "PAST DUE"
elif hours_left < 24:
time_info = f"in {hours_left:.0f}h"
day_name = a["due_at"].strftime("%A")
assignment_lines.append(
f"- {a['name']} ({a['course']}) - due {day_name} {a['due_at'].strftime('%I:%M %p')} ({time_info})"
)
assignments_text = "\n".join(assignment_lines)
if is_monday:
prompt = (
f"You are a helpful assignment assistant. Summarize these assignments due this week "
f"into a concise text message. Group by day. Keep it under 500 characters. "
f"Be casual and friendly. Here are the assignments:\n\n{assignments_text}"
)
else:
prompt = (
f"You are a helpful assignment assistant. Summarize these assignments due today "
f"into a concise text message. Keep it under 300 characters. "
f"Be casual and friendly. Here are the assignments:\n\n{assignments_text}"
)
return prompt
def generate_summary(assignments, is_monday=False):
prompt = build_prompt(assignments, is_monday)
if prompt is None:
return "Nothing due today! You're all caught up."
response = chat(
model="llama3.2:3b",
messages=[{"role": "user", "content": prompt}]
)
return response.message.content
Wire sense-think-act together
The AI agent pattern follows three stages: sense (gather data), think (process it with AI), and act (do something with the result). Your main script will orchestrate all three.
- Create a new file called agent.py in your project folder.
- Add the imports and logging configuration by pasting this code:
from datetime import datetime, timezone
from calendar_client import get_todays_assignments, get_weeks_assignments
from summarizer import generate_summary
from sms_sender import send_text
import logging
# Log all agent activity to a file for debugging
logging.basicConfig(
filename="agent.log",
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s"
)
What does this code do?
These imports wire together all the modules of your agent. The calendar_client handles sensing, the summarizer handles thinking, and sms_sender will handle the act stage (sending the text).
The logging configuration writes timestamped entries to agent.log so you can debug issues when the agent runs unattended.
Why import sms_sender if it doesn't exist yet?
You'll create sms_sender.py in the next step. For now, agent.py won't run on its own. You'll test the summarizer in isolation below, then everything will connect once the SMS module is ready.
- Below the logging config, add the run_agent() function and the entry point:
def run_agent():
logging.info("Agent starting...")
# Detect if today is Monday for weekly summaries
now = datetime.now(timezone.utc)
is_monday = now.weekday() == 0
try:
# SENSE: fetch assignments from Canvas
if is_monday:
assignments = get_weeks_assignments()
logging.info(f"Monday run: found {len(assignments)} assignments this week")
else:
assignments = get_todays_assignments()
logging.info(f"Daily run: found {len(assignments)} assignments due today")
# THINK: generate a summary with the LLM
summary = generate_summary(assignments, is_monday)
logging.info(f"Generated summary: {summary[:100]}...")
# ACT: send the summary as a text message
send_text(summary)
logging.info("Text sent successfully")
except Exception as e:
logging.error(f"Agent failed: {e}")
raise
if __name__ == "__main__":
run_agent()
What does this code do?
- The run_agent() function is the orchestrator that ties your three modules together.
- It uses now.weekday() == 0 to detect Monday (weekday 0 in Python) and fetch the full week's assignments instead of just today's.
- Every step is logged with timestamps so you can trace what happened if a scheduled run fails silently.
- The try/except block catches any failure (network issues, Ollama not running) and logs it before re-raising.
- Save agent.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved agent.py before moving on to the test.
ⓧ I'd like to double check the full code
from datetime import datetime, timezone
from canvas_client import get_todays_assignments, get_weeks_assignments
from summarizer import generate_summary
from sms_sender import send_text
import logging
logging.basicConfig(
filename="agent.log",
level=logging.INFO,
format="%(asctime)s - %(levelname)s - %(message)s"
)
def run_agent():
logging.info("Agent starting...")
now = datetime.now(timezone.utc)
is_monday = now.weekday() == 0
try:
if is_monday:
assignments = get_weeks_assignments()
logging.info(f"Monday run: found {len(assignments)} assignments this week")
else:
assignments = get_todays_assignments()
logging.info(f"Daily run: found {len(assignments)} assignments due today")
summary = generate_summary(assignments, is_monday)
logging.info(f"Generated summary: {summary[:100]}...")
send_text(summary)
logging.info("Text sent successfully")
except Exception as e:
logging.error(f"Agent failed: {e}")
raise
if __name__ == "__main__":
run_agent()
Test the AI output
Since sms_sender.py doesn't exist yet, you can't run agent.py directly. Instead, you'll test the Canvas-to-LLM pipeline by importing the summarizer and canvas client together.
- Make sure Ollama is running in the background. Confirm by running:
ollama list
- If you see llama3.2:3b in the output, Ollama is ready. If you get an error, open the Ollama app from your Applications folder to start it.
- Return to your project terminal and test the full sense-think pipeline by running:
python3 << 'EOF'
from canvas_client import get_todays_assignments
from summarizer import generate_summary
assignments = get_todays_assignments()
print(f"Found {len(assignments)} assignments due today")
print("\nLLM Summary:")
print(generate_summary(assignments))
EOF
What does this command do?
This runs a quick Python script inline (using a heredoc) that imports your canvas client and summarizer without needing a separate file. It fetches today's assignments from Canvas, passes them to the LLM, and prints the generated summary.
The first time you run this, it may take 10-20 seconds for the LLM to respond. Subsequent calls are faster because Ollama keeps the model loaded in memory.
- You should see a count of assignments followed by a short, casual text-message-style summary containing your actual assignment names and due times.
Getting a connection error from Ollama?
Ollama needs to be running as a background service before your script can talk to it. Open a new terminal window and run ollama serve to start it manually. Then try the test command again.
If you see a model-not-found error, re-pull the model with ollama pull llama3.2:3b.
Help me debug this
💡 No assignments due today?
If the Canvas API returns zero assignments for today, you'll see the fallback message: "Nothing due today! You're all caught up." This is expected behavior. To test the LLM path, swap get_todays_assignments() for get_weeks_assignments() in the test command to look at the full week instead.
Your sense-think pipeline is working. The agent can pull assignments from Canvas and turn them into a clean text message. Next up, you'll build the SMS sender so the agent can actually deliver that summary to your phone.
Send the Text Message
Your agent can now sense assignments from Canvas and think about them with Ollama. The final piece of the sense-think-act loop is the "act" step. It needs to deliver that summary to your phone as a real text message.
In this step, you'll use Python's built-in email libraries to send your summary as an SMS through AT&T's email-to-SMS gateway. No paid APIs required.
In this step, get ready to:
- Write the SMS sender module using Python's built-in smtplib.
- Connect SMS sending into your agent's pipeline.
- Run the full agent end-to-end and receive a text on your phone.
Write the SMS sender module
Email-to-SMS gateways let you send a text message by emailing a special address. For AT&T, sending an email to your 10-digit phone number @txt.att.net delivers it as an SMS on your phone.
Why email-to-SMS instead of a paid API?
Services like Twilio charge per message and require account setup. The email-to-SMS gateway is completely free and uses Python's built-in smtplib library with no extra dependencies.
- Create a new file called sms_sender.py in your project folder.
- Add the imports and environment variable loading by pasting this code:
import smtplib
import ssl
from email.mime.text import MIMEText
from dotenv import load_dotenv
import os
# Load credentials from .env file
load_dotenv()
# Pull email and phone config from environment
GMAIL_ADDRESS = os.getenv("GMAIL_ADDRESS")
GMAIL_APP_PASSWORD = os.getenv("GMAIL_APP_PASSWORD")
PHONE_NUMBER = os.getenv("PHONE_NUMBER")
CARRIER_GATEWAY = os.getenv("CARRIER_GATEWAY")
What does this code do?
- smtplib and ssl are built-in Python libraries for sending emails over a secure connection.
- MIMEText formats your message into a proper email structure that SMTP servers can process.
- The four environment variables pull your Gmail credentials and phone details from .env so nothing sensitive lives in the code.
- Save sms_sender.py.
You won't see a change yet. The next chunk adds the function that actually sends the message.
- Below the config section in sms_sender.py, add the send_text() function and the standalone test block:
def send_text(message):
# Build the gateway email address from phone number and carrier
recipient = f"{PHONE_NUMBER}@{CARRIER_GATEWAY}"
# Format the message as a proper email
msg = MIMEText(message)
msg["From"] = GMAIL_ADDRESS
msg["To"] = recipient
msg["Subject"] = ""
# Create a secure SSL connection to Gmail's SMTP server
context = ssl.create_default_context()
with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=context) as server:
server.login(GMAIL_ADDRESS, GMAIL_APP_PASSWORD)
server.sendmail(GMAIL_ADDRESS, recipient, msg.as_string())
print(f"Text sent to {PHONE_NUMBER}")
if __name__ == "__main__":
send_text("Test message from your Canvas agent!")
What does this code do?
- recipient combines your phone number with the AT&T gateway (e.g. 5551234567@txt.att.net). Gmail delivers the email to this address, and AT&T converts it to an SMS.
- smtplib.SMTP_SSL connects to smtp.gmail.com on port 465 using SSL encryption. This is the secure channel Gmail requires for sending mail.
- The if __name__ == "__main__" block lets you test the module by itself before connecting it to the agent.
- Save sms_sender.py.
- Test the module standalone by running this command:
python sms_sender.py
Within a few seconds, you should receive a text message on your phone that says "Test message from your Canvas agent!" and see Text sent to followed by your number printed in the terminal.
Not receiving the text?
- Double check your PHONE_NUMBER in .env is exactly 10 digits with no dashes or spaces.
- Verify your GMAIL_APP_PASSWORD is the 16-character app password, not your regular Gmail password. Your Google account must have 2FA enabled.
- Check that CARRIER_GATEWAY is set to txt.att.net in your .env file.
- If you see an authentication error, regenerate your app password at myaccount.google.com/apppasswords.
help me debug why my SMS is not sending
✔️ Awesome, I've got everything!
Great. Double check you have saved sms_sender.py before moving on.
ⓧ I'd like to double check the full code
import smtplib
import ssl
from email.mime.text import MIMEText
from dotenv import load_dotenv
import os
load_dotenv()
GMAIL_ADDRESS = os.getenv("GMAIL_ADDRESS")
GMAIL_APP_PASSWORD = os.getenv("GMAIL_APP_PASSWORD")
PHONE_NUMBER = os.getenv("PHONE_NUMBER")
CARRIER_GATEWAY = os.getenv("CARRIER_GATEWAY")
def send_text(message):
recipient = f"{PHONE_NUMBER}@{CARRIER_GATEWAY}"
msg = MIMEText(message)
msg["From"] = GMAIL_ADDRESS
msg["To"] = recipient
msg["Subject"] = ""
context = ssl.create_default_context()
with smtplib.SMTP_SSL("smtp.gmail.com", 465, context=context) as server:
server.login(GMAIL_ADDRESS, GMAIL_APP_PASSWORD)
server.sendmail(GMAIL_ADDRESS, recipient, msg.as_string())
print(f"Text sent to {PHONE_NUMBER}")
if __name__ == "__main__":
send_text("Test message from your Canvas agent!")
Integrate SMS into the agent
Your agent.py already imports send_text from sms_sender and calls it after generating the summary. Now that sms_sender.py exists, the full pipeline is connected.
- Switch back to agent.py and find these lines inside the try block:
summary = generate_summary(assignments, is_monday)
logging.info(f"Generated summary: {summary[:100]}...")
send_text(summary)
logging.info("Text sent successfully")
How does error handling work here?
The send_text(summary) call sits inside the existing try/except block. If the SMTP connection fails (wrong password, network issue, or server timeout), the exception is caught and logged to agent.log.
This means a single try/except wraps the entire sense-think-act loop. Any failure at any stage gets captured in the log file for debugging.
Test end-to-end
Time to run the complete agent and confirm all three stages work together: fetching from Canvas, summarizing with the LLM, and texting your phone.
- Run the full agent by executing this command:
python agent.py
Within 10-30 seconds (the LLM needs time to generate), you should receive a real text message on your phone with your assignment summary.
What should I see?
In the terminal, you will see the Text sent to confirmation. On your phone, an SMS arrives with a casual, friendly summary of your assignments.
If today is Monday, the message includes your full week. Otherwise it covers today's assignments only. If nothing is due, you will get "Nothing due today! You're all caught up."
- Check agent.log to see the full run recorded by running:
cat agent.log
You should see lines showing the agent starting, how many assignments it found, a preview of the generated summary, and confirmation that the text was sent successfully.
Agent fails or no text received?
- Run cat agent.log to check for error messages. The log tells you exactly which stage failed (Canvas fetch, LLM generation, or SMS sending).
- If you see an SMTP error, run python sms_sender.py standalone to isolate whether the problem is credentials or the gateway address.
- If Ollama is slow or unresponsive, make sure the Ollama app is running in your menu bar. The agent needs the local server at localhost:11434 to generate summaries.
help me debug why my Canvas agent is not sending the text
Your agent is fully operational. It can sense assignments, think about them, and act by texting you a summary. Next up, you will schedule it to run every morning automatically so you never have to trigger it by hand.
Schedule with Cron
Your agent works perfectly when you run it by hand. But the whole point of an agent is that it acts autonomously without you needing to remember.
In this step, you will schedule the agent to run every morning at 7:00 AM using cron, a built-in macOS job scheduler. Once the cron job is active, you will wake up every day to a text message with your assignments.
In this step, get ready to:
- Create a cron job that runs your agent at 7:00 AM daily.
- Test the schedule by setting a cron for 2 minutes from now.
- Confirm edge cases like logging and empty assignment handling work for scheduled runs.
Create the cron job
Cron is a time-based scheduler built into macOS. You give it a schedule expression (like "every day at 7 AM") and a command, and it runs that command automatically in the background.
What is cron?
Cron reads a file called a "crontab" (cron table) that lists jobs and their schedules. Each line is one job with five time fields (minute, hour, day-of-month, month, day-of-week) followed by the command to run.
For example, 0 7 * * * means "at minute 0 of hour 7, every day of every month, every day of the week."
Before creating the job, you need to know the full paths to your virtual environment's Python interpreter and your agent.py file. Cron does not load your shell profile, so it cannot find commands by their short names.
- Find the full path to your virtual environment's Python by running this command:
which python
What does this command do?
The which command prints the absolute path of the Python binary your shell is currently using. Since your virtual environment is active, this returns the path inside your venv folder.
- Copy the path it prints. It looks something like /Users/yourname/Desktop/canvas-agent/venv/bin/python.
- Find the full path to your agent.py file by running:
pwd
- Copy this path and append /agent.py to it. For example: /Users/yourname/Desktop/canvas-agent/agent.py.
Now you have both paths. Time to create the cron job.
- Open your crontab for editing by running this command:
EDITOR=nano crontab -e
What does this command do?
The crontab -e command opens your personal cron table in a text editor. Setting EDITOR=nano ensures it opens in nano, which is easier to use than the default vim editor.
- In the nano editor that opens, type the following cron line (replacing the paths with the ones you copied earlier):
0 7 * * * PATH=/usr/local/bin:$PATH [[VENV_PYTHON="/path/to/venv/bin/python"]] [[AGENT_PATH="/path/to/agent.py"]]
What does this cron line mean?
- 0 7 * * * runs the job at 7:00 AM every day.
- PATH=/usr/local/bin:$PATH ensures cron can find Ollama, which lives in /usr/local/bin. Without this, the Ollama call inside your script would fail.
- The full path to your venv Python ensures cron uses the correct interpreter with all your installed packages.
- The full path to agent.py tells cron exactly which script to execute.
- Save the file by pressing Ctrl+O, then Enter.
- Exit nano by pressing Ctrl+X.
- Verify the cron job was saved by running:
crontab -l
- You should see your cron line printed back in the terminal.
Seeing "Operation not permitted"?
macOS requires you to grant cron permission to run jobs. Open System Settings, then go to Privacy & Security, then select Full Disk Access.
Click the + button, press Cmd+Shift+G, type /usr/sbin/cron, press Go, and add it to the list. Then try crontab -e again.
Help me fix cron permission issues on macOS
Verify the schedule
You do not want to wait until 7:00 AM tomorrow to find out your cron job has a typo. Set a temporary cron job for 2 minutes from now to test that everything works.
- Check the current time by running:
date '+%H:%M'
- Open your crontab again by running:
EDITOR=nano crontab -e
- Change the time fields to 2 minutes from now. For example, if the current time is 14:30, change 0 7 to 32 14 (minute 32, hour 14).
- Save with Ctrl+O then Enter, and exit with Ctrl+X.
- Wait 2 minutes and check your phone.
You should receive a text message with your assignment summary. This confirms cron, Python, Ollama, and the SMS gateway all work together without manual intervention.
- After confirming the test works, open your crontab one more time and change the time back to 0 7 for the daily 7:00 AM schedule:
EDITOR=nano crontab -e
- Update the time fields back to 0 7 * * *, save, and exit.
Didn't receive a text?
Check your agent.log file for error messages. If the file is empty, cron may not have run the job at all. Verify the paths in your cron line are correct by running them manually in Terminal.
Also confirm Ollama is running. Cron starts with a minimal environment, so if Ollama is not active as a background service, the LLM call will fail. Ollama starts automatically on login by default.
Help me debug why my cron job isn't sending texts
Handle edge cases
Your agent already handles two important edge cases for scheduled runs. Let's confirm they work correctly so you can trust the cron job to run unattended all semester.
The first edge case is days when nothing is due. If Canvas returns no assignments, the agent sends a "nothing due" message instead of crashing or sending a blank text.
- Verify this works by checking the generate_summary function in summarizer.py. When no assignments are found, it returns the string Nothing due today! You're all caught up. which gets sent as the SMS.
The second edge case is debugging failed runs. Since cron runs silently in the background, you need a way to see what happened.
- Check that logging is working by viewing the log file:
cat agent.log
- You should see timestamped log entries from your test run, including lines like Agent starting... and Text sent successfully.
Why logging matters for cron jobs
When a script runs via cron, there is no terminal window to show errors or print statements. The agent.log file captures everything the agent does and any errors it encounters.
If you stop receiving texts one morning, open agent.log to see exactly what went wrong. The timestamps tell you when each run happened and the error messages pinpoint the failure.
🙋♀️ Log file is empty?
If agent.log has no entries from your cron test, the cron job may not have run at all. Run crontab -l to confirm the job is saved. Also check that the Python path in your cron line points to the venv interpreter, not the system Python.
Help me figure out why my agent.log is empty after a cron run
✔️ Awesome, I've got everything!
Great. Double check that your cron job is set to 0 7 * * * and that agent.log shows entries from your test run.
ⓧ I'd like to double check the full cron line
Your crontab entry should look like this (with your actual paths substituted):
0 7 * * * PATH=/usr/local/bin:$PATH /path/to/venv/bin/python /path/to/agent.py
Your AI agent is now fully autonomous. Every morning at 7:00 AM, it wakes up, pulls your assignments from Canvas, summarizes them with a local LLM, and texts you the results. Next up, take on a Secret Mission to make the agent even smarter with priority sorting.
Secret mission
Priority Sorting with Urgency Indicators
Your agent tells you what's due, but it treats every assignment equally. A paper due in 3 hours gets the same treatment as one due next Thursday. Time to make it smarter. Add urgency-aware sorting so the most pressing deadlines jump out first in every text you receive.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your Mac with no paid cloud services. There are no ongoing costs. Decide whether to keep your agent running, pause it, or remove everything.
Resources you used:
- Canvas API access token (free, persists until you delete it).
- Gmail app password (free, persists until you revoke it).
- Ollama application and model files stored in ~/.ollama (free, uses disk space).
- Cron job (free, runs on your Mac every morning).
- Project folder with Python virtual environment and scripts.
Keep everything running
No action needed. Choose this if you want to keep receiving daily assignment texts all semester.
- Your cron job fires every morning at 7:00 AM and sends a text to your phone.
- Ollama runs as a login item. It starts automatically when you reboot your Mac.
- Your Canvas token and Gmail app password remain active.
Pause - I'll come back to this later
Disable the daily text without deleting anything. You can re-enable it later by removing the comment character.
- Open your crontab by running:
crontab -e
- Add a # at the beginning of your cron line to comment it out.
- Save and exit. The agent will no longer run on schedule.
- To resume later, open crontab -e again and remove the # character.
Delete - I don't want to use this again
Remove all project resources from your Mac and revoke any credentials you created.
- Remove all cron jobs by running:
crontab -r
- Revoke your Canvas API token. Go to https://canvas.utexas.edu/profile/settings, scroll to Approved Integrations, and click the delete icon next to your token.
- Revoke your Gmail app password. Go to https://myaccount.google.com/apppasswords and click Remove next to the app password you created.
- Delete your project folder by running:
cd ~/Desktop
rm -rf canvas-agent
- Uninstall Ollama and remove all downloaded models by running:
pkill -f ollama
sudo rm -rf /Applications/Ollama.app
sudo rm /usr/local/bin/ollama
rm -rf ~/.ollama
What do these commands do?
The first line stops any running Ollama processes. The next two lines remove the Ollama application and its command-line tool (your Mac will ask for your password). The last line deletes all downloaded model files from ~/.ollama.
Nice Work!
Nice Work!
You just built a fully autonomous AI agent that wakes up every morning, checks your Canvas assignments, and texts you a summary. That is a real-world agent workflow running entirely on your Mac.
Here's what you learned:
- Built a Python AI agent using the sense-think-act pattern to fetch assignments from the Canvas REST API and compose natural-language summaries with a local LLM.
- Sent real SMS messages to your phone for free using AT&T's email-to-SMS gateway and Python's built-in smtplib.
- Scheduled the agent with a cron job to run every morning without manual intervention.
- Secret Mission: Added priority sorting with urgency indicators so the most pressing deadlines appear first in your daily text.
Ready to quiz yourself?