- Published on
Running Gemma AI Locally to Build an Email and Calendar Agent
- Authors

- Name
- Douglas Montanus
Introduction
I wanted a fully local AI agent that could handle a slice of my inbox — read an email, decide what it needs, draft a reply, and if a meeting is implied, create a calendar event. No cloud API costs for inference, no email content leaving my machine.
The stack I landed on:
- Gemma 3 running locally via Ollama
- Gmail API for reading and sending email
- Google Calendar API for creating events
- A Python agent loop that wires it all together with tool calling
This post walks through setting it up, the parts that were tricky, and where Claude Code helped.
Step 1: Getting Gemma Running with Ollama
Ollama is the easiest way to run open-weight models locally. It handles model downloads, quantization, and exposes a local HTTP API that is compatible with the OpenAI SDK — so any code written against OpenAI's API works with Ollama by changing the base URL.
# Install Ollama (macOS / Linux)
curl -fsSL https://ollama.com/install.sh | sh
# Pull Gemma 3 — the 4B model is a good balance of speed and quality
ollama pull gemma3:4b
Verify it works:
ollama run gemma3:4b "Say hello in one sentence."
Ollama's server runs on http://localhost:11434 by default. From Python, the openai package talks to it directly:
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
response = client.chat.completions.create(
model="gemma3:4b",
messages=[{"role": "user", "content": "Hello!"}],
)
print(response.choices[0].message.content)
Step 2: Google API Credentials
Both Gmail and Google Calendar use the same OAuth2 flow. I asked Claude Code to walk me through it:
"Set up Google OAuth2 credentials for Gmail and Calendar access in a Python project. I want a reusable token file so I only authenticate once."
It generated the setup script and explained each step:
- Go to Google Cloud Console, create a project, and enable Gmail API and Google Calendar API.
- Create an OAuth 2.0 Client ID (Desktop app type), download the
credentials.jsonfile. - Run the auth script once to produce a
token.jsonthat the agent reuses.
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
import os
SCOPES = [
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
]
def get_credentials() -> Credentials:
creds = None
if os.path.exists("token.json"):
creds = Credentials.from_authorized_user_file("token.json", SCOPES)
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)
with open("token.json", "w") as f:
f.write(creds.to_json())
return creds
Run python auth.py once, complete the browser flow, and token.json is written. The agent uses get_credentials() from that point forward — no browser required.
Step 3: The Tool Definitions
Gemma 3 supports tool calling. I defined three tools for the agent: one to fetch unread emails, one to send a reply, and one to create a calendar event.
I described the shape of each tool to Claude Code and it wrote the implementations:
import base64
import json
from datetime import datetime, timedelta
from email.mime.text import MIMEText
from googleapiclient.discovery import build
from auth import get_credentials
creds = get_credentials()
gmail = build("gmail", "v1", credentials=creds)
calendar = build("calendar", "v3", credentials=creds)
def get_unread_emails(max_results: int = 5) -> list[dict]:
result = gmail.users().messages().list(
userId="me", labelIds=["UNREAD"], maxResults=max_results
).execute()
messages = result.get("messages", [])
emails = []
for msg in messages:
full = gmail.users().messages().get(userId="me", id=msg["id"], format="full").execute()
headers = {h["name"]: h["value"] for h in full["payload"]["headers"]}
body = ""
payload = full.get("payload", {})
if "parts" in payload:
for part in payload["parts"]:
if part["mimeType"] == "text/plain":
body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8")
break
emails.append({
"id": msg["id"],
"from": headers.get("From", ""),
"subject": headers.get("Subject", ""),
"body": body[:1000], # truncate for context window
})
return emails
def send_reply(message_id: str, to: str, subject: str, body: str) -> str:
mime = MIMEText(body)
mime["To"] = to
mime["Subject"] = subject
raw = base64.urlsafe_b64encode(mime.as_bytes()).decode("utf-8")
gmail.users().messages().send(userId="me", body={"raw": raw, "threadId": message_id}).execute()
gmail.users().messages().modify(
userId="me", id=message_id, body={"removeLabelIds": ["UNREAD"]}
).execute()
return f"Reply sent to {to}."
def create_calendar_event(title: str, date: str, duration_minutes: int, description: str = "") -> str:
start = datetime.fromisoformat(date)
end = start + timedelta(minutes=duration_minutes)
event = {
"summary": title,
"description": description,
"start": {"dateTime": start.isoformat(), "timeZone": "America/New_York"},
"end": {"dateTime": end.isoformat(), "timeZone": "America/New_York"},
}
created = calendar.events().insert(calendarId="primary", body=event).execute()
return f"Event created: {created.get('htmlLink')}"
Step 4: The Agent Loop
The agent loop sends emails to Gemma along with the tool definitions, then executes whichever tools it calls, feeds results back, and repeats until Gemma produces a final answer with no more tool calls.
import json
from openai import OpenAI
from tools import get_unread_emails, send_reply, create_calendar_event
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "gemma3:4b"
TOOLS = [
{
"type": "function",
"function": {
"name": "get_unread_emails",
"description": "Fetch the most recent unread emails from Gmail.",
"parameters": {
"type": "object",
"properties": {
"max_results": {"type": "integer", "default": 5}
},
},
},
},
{
"type": "function",
"function": {
"name": "send_reply",
"description": "Send a reply to an email and mark it as read.",
"parameters": {
"type": "object",
"required": ["message_id", "to", "subject", "body"],
"properties": {
"message_id": {"type": "string"},
"to": {"type": "string"},
"subject": {"type": "string"},
"body": {"type": "string"},
},
},
},
},
{
"type": "function",
"function": {
"name": "create_calendar_event",
"description": "Create a Google Calendar event.",
"parameters": {
"type": "object",
"required": ["title", "date", "duration_minutes"],
"properties": {
"title": {"type": "string"},
"date": {"type": "string", "description": "ISO 8601 datetime, e.g. 2026-06-20T14:00:00"},
"duration_minutes": {"type": "integer"},
"description": {"type": "string"},
},
},
},
},
]
TOOL_FNS = {
"get_unread_emails": get_unread_emails,
"send_reply": send_reply,
"create_calendar_event": create_calendar_event,
}
SYSTEM = """You are a helpful email assistant. When asked to handle emails:
1. Fetch unread emails.
2. For each email that needs a reply, draft and send a professional response.
3. If an email implies scheduling a meeting, create a calendar event and mention it in the reply.
Be concise. Do not ask for confirmation before sending — act directly."""
def run_agent(user_prompt: str) -> str:
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_prompt},
]
while True:
response = client.chat.completions.create(
model=MODEL, messages=messages, tools=TOOLS, tool_choice="auto"
)
msg = response.choices[0].message
messages.append(msg)
if not msg.tool_calls:
return msg.content
for call in msg.tool_calls:
fn = TOOL_FNS[call.function.name]
args = json.loads(call.function.arguments)
result = fn(**args)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": str(result),
})
if __name__ == "__main__":
print(run_agent("Check my unread emails and handle anything that needs a reply."))
Step 5: Running It
python agent.py
The agent fetches unread emails, decides which ones need replies, drafts and sends them, creates calendar events where appropriate, and prints a summary of what it did — all running against a local Gemma model.
What Claude Code Helped With
The Gmail body extraction. Gmail's API returns messages in a multipart MIME structure that is not obvious. I asked Claude Code why my body was coming back empty, and it identified that I needed to walk the parts array and decode the base64 payload — which became the loop in get_unread_emails.
The tool calling format for Ollama. Ollama's tool call response format differs slightly from OpenAI's in edge cases. When the agent loop broke on a malformed tool call response, I pasted the raw JSON and Claude Code adjusted the parsing.
The system prompt. My first version had the agent asking for confirmation before sending each reply, which defeated the purpose. Claude Code rewrote the system prompt to instruct it to act directly, and also pointed out that the instruction needed to be explicit or Gemma would default to cautious behavior.
Caveats
- Gemma 3 4B is capable but not infallible. Review a few runs before letting it handle real email unsupervised.
- The body truncation at 1000 characters means very long emails may lose context. Increase or remove the limit if you have the VRAM for it.
- The timezone is hardcoded — update
America/New_Yorkincreate_calendar_eventto match yours.
Conclusion
Running a model locally with Ollama removes the cost and privacy concerns of sending email content to a cloud API. Gemma 3 is good enough for structured tasks like this, especially with clear tool definitions and a well-crafted system prompt.
The agent is not magic — it is a tight loop between a language model and three API calls. But that loop handles a real chunk of email overhead, and the whole thing runs on my laptop.