Build a Calendar-Scheduling Agent That Negotiates Over Email with Gemini Free Tier
What We're Building
We are building an autonomous scheduling agent that lives inside your email. Instead of the tedious back-and-forth of finding a meeting time, this bot intercepts scheduling requests, cross-references your real availability, and sends a polite counter-proposal with specific slots.
Feature List:
- Monitors Gmail inbox for incoming scheduling requests.
- Uses Google Gemini (free tier) to extract the requested time, duration, and meeting intent.
- Queries Google Calendar for your free/busy status.
- If the requested time is free, it confirms the meeting.
- If the requested time is busy, it finds the next 3 available windows and negotiates a counter-offer.
- Sends a professionally drafted reply via Gmail.
This isn't a rigid Zapier zap that breaks on "next Tuesday." This is an LLM-powered agent that understands natural language.
Architecture: The Negotiation Flow
Before we touch code, let’s map the data flow. The agent is a pipeline triggered by an email event, processed by Gemini, and enriched by Calendar data.
We are using n8n as the orchestration layer. It’s a free, open-source workflow automation tool that connects APIs without managing OAuth tokens manually. The "Code" nodes handle the state logic that LLMs are bad at.
Prerequisites (All Free Tier)
You don't need a credit card for the core functionality, though Google Cloud Console might ask for one to verify you aren't a bot (no charges will be made on the free tier).
- Google Cloud Project
- Go to console.cloud.google.com.
- Create a new project.
- Enable the Gmail API and Google Calendar API.
- Google Gemini API Key
- Visit aistudio.google.com/apikey.
- Click "Create API Key" in a free-tier supported region.
- We will use
gemini-1.5-flashfor speed and zero cost.
- n8n Instance
- Option A: Self-host via Docker (free):
docker run -it --rm --name n8n -p 5678:5678 n8nio/n8n - Option B: Use the free cloud tier at n8n.cloud (limited workflows).
- Option A: Self-host via Docker (free):
Step 1: Google Cloud Project & APIs
This is the most bureaucratic part. Don't skip the OAuth consent screen.
- Enable APIs: In the GCP library, search for and enable
Gmail APIandGoogle Calendar API. - OAuth Consent Screen: Go to
APIs & Services > OAuth consent screen. ChooseExternal(since you are testing). Fill in just the App name and email. You don't need scopes here yet; we'll specify them in n8n. - Credentials: Create an
OAuth Client ID. ChooseWeb application. Addhttp://localhost:5678/rest/oauth2-credential/callbackto Authorized redirect URIs (or your n8n cloud domain). - Publishing: Click "Publish App" under the consent screen to avoid the "unverified app" warning (it's fine for testing, but publishing removes friction).
Step 2: n8n Setup & Credentials
Open your n8n instance. We need to register the credentials.
- Gmail OAuth2 API: Add a new credential. Select
OAuth2 API. Paste your Client ID and Secret from Step 1. Set Scope tohttps://www.googleapis.com/auth/gmail.modify. - Google Calendar OAuth2 API: Same process, but Scope is
https://www.googleapis.com/auth/calendar.readonly. - Google Gemini API: Add an
HTTP Header Authcredential. Name itGeminiKey. Value:X-goog-api-key: YOUR_API_KEY.
Step 3: Building the Gmail Trigger
Create a new workflow in n8n.
- Add a Gmail Trigger node.
- Credential: Your Gmail account.
- Events:
Message Received. - Simplify:
true(this strips attachments and gives us clean text). - Polling: Every 1 minute (free tier limits are generous enough for this).
We need the raw text of the latest reply, not the entire thread history. Add a Function node immediately after. We'll call it "Clean Thread".
// Input: items from Gmail trigger
const items = $input.all();
for (const item of items) {
// Gmail 'simplified' mode gives us headers and text
let subject = item.json.headers?.subject || "";
let body = item.json.textPlain || "";
// Reject automated emails we don't want to process
if (subject.includes("Automated") || body.includes("unsubscribe")) {
continue;
}
// Clean up reply chains - take only the latest message chunk
// Often replies start with "On Mon, ... wrote:"
const replyMarker = body.indexOf("On ");
if (replyMarker > 0) {
body = body.substring(0, replyMarker).trim();
}
item.json.cleanText = body;
item.json.threadId = item.json.threadId;
}
return items;
Step 4: Extracting Intent with Gemini
We need to parse the human's messy text into structured JSON. Add an HTTP Request node. Connect it to the "Clean Thread" node.
- Method:
POST - URL:
https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent - Authentication:
Generic Credential Type->GeminiKey - Body (JSON):
{
"contents": [{
"parts": [{
"text": "Extract the scheduling request from this email. Return ONLY valid JSON. Keys: 'requestedDate' (ISO 8601 or null), 'requestedTime' (HH:MM 24h or null), 'durationMinutes' (int), 'purpose' (string). If no specific time is requested, infer 'null' for time. Email text: {{ $json.cleanText }}"
}]
}],
"generationConfig": {
"response_mime_type": "application/json"
}
}
Note: Gemini 1.5 Flash supports controlled generation. Setting response_mime_type forces JSON output without markdown fences.
Add another Function node "Parse Gemini". Gemini returns the JSON inside a nested path.
const items = $input.all();
for (const item of items) {
const response = item.json.candidates[0].content.parts[0].text;
const parsed = JSON.parse(response);
item.json.scheduleRequest = parsed;
}
return items;
Step 5: Checking Calendar Availability
We need to query Google Calendar Free/Busy. Add a Google Calendar node.
- Resource:
FreeBusy - Operation:
Get Free/Busy - Calendar ID:
primary(or your specific email). - Time Min: We need to calculate this dynamically based on the parsed date. Use an expression:
{{ DateTime.fromISO($json.scheduleRequest.requestedDate).startOf('day').toISO() }} - Time Max: End of the day:
{{ DateTime.fromISO($json.scheduleRequest.requestedDate).endOf('day').toISO() }}
This node returns an array of busy slots. We'll pass this to the negotiation logic.
Step 6: The Negotiation Logic Node
This is the brain. Add a Code node. We compare the requested time against the busy slots.
const items = $input.all();
const workingHours = { start: 9, end: 17 }; // 9 AM to 5 PM
for (const item of items) {
const req = item.json.scheduleRequest;
const busySlots = item.json.busy || []; // from Google Calendar node
// Convert requested time to a Date object
let requestedStart = null;
if (req.requestedDate && req.requestedTime) {
requestedStart = new Date(`${req.requestedDate}T${req.requestedTime}:00`);
}
let status = 'UNKNOWN';
let counterSlots = [];
if (!requestedStart) {
// No specific time requested, just "this week"
status = 'VAGUE';
counterSlots = findFreeSlots(new Date(req.requestedDate), busySlots, workingHours, 3);
} else {
const conflict = busySlots.some(slot => {
const start = new Date(slot.start);
const end = new Date(slot.end);
return requestedStart >= start && requestedStart < end;
});
if (conflict) {
status = 'CONFLICT';
counterSlots = findFreeSlots(new Date(req.requestedDate), busySlots, workingHours, 3);
} else {
status = 'FREE';
}
}
item.json.negotiation = {
status,
requestedStart,
counterSlots
};
}
function findFreeSlots(date, busySlots, hours, count) {
const slots = [];
let current = new Date(date);
current.setHours(hours.start, 0, 0, 0);
const end = new Date(date);
end.setHours(hours.end, 0, 0, 0);
while (current < end && slots.length < count) {
const slotEnd = new Date(current.getTime() + 30 * 60000);
const isBusy = busySlots.some(b => {
return current < new Date(b.end) && slotEnd > new Date(b.start);
});
if (!isBusy) {
slots.push(new Date(current));
}
current = new Date(current.getTime() + 30 * 60000);
}
return slots;
}
return items;
Step 7: Crafting the Response Email
We call Gemini again, this time to write a polite email. Add an HTTP Request node.
{
"contents": [{
"parts": [{
"text": "Draft a short, polite email reply based on this JSON context. If status is FREE, confirm the meeting. If CONFLICT, explain the conflict and offer the counter slots. If VAGUE, ask for clarification but suggest slots. Sign off as 'Automated Scheduling Assistant'. Context: {{ JSON.stringify($json.negotiation) }}. Original request purpose: {{ $json.scheduleRequest.purpose }}"
}]
}]
}
Extract the text from the response exactly like Step 4. Then add a Gmail node.
- Resource:
Message - Operation:
Reply to Message - Thread ID:
{{ $json.threadId }} - Message Body:
{{ $json.generatedReply }}
Running the Agent
- Click "Test Workflow" in n8n. Send yourself an email from a different account saying "Can we meet tomorrow at 2 PM for 30 mins to discuss Q3 planning?"
- Watch the execution log. You should see the JSON parsing, the Calendar query, and the reply landing in your inbox.
- If it works, click "Active" to turn on polling.
Sensible Extensions
- Memory: Store confirmed meeting IDs in a Google Sheet. If a reply comes in on the same thread, check the sheet to avoid re-processing a confirmed meeting. This prevents loops.
- Time Zone Handling: The current logic assumes UTC/local parity. Add a timezone offset parameter to the Gemini prompt ("Extract the timezone mentioned or assume America/Chicago").
- Priority Filtering: Add a label check in the Gmail trigger. Only process emails with a "VIP" label to avoid negotiating with newsletters.
- Alternative Models: If you hit Gemini free tier rate limits, swap the HTTP node to use Groq's free tier with Llama 3. This is a drop-in replacement we covered in our Gmail Triage Agent guide.
Common Pitfalls
- OAuth Loop of Death: If n8n keeps redirecting to Google login, verify the redirect URI is exactly
http://localhost:5678/rest/oauth2-credential/callback. Trailing slashes matter. - Gemini JSON Mode Failures: The free tier sometimes wraps JSON in markdown fences even with
response_mime_type. Use a regex fallback in the Parse node:response.replace(/```json|```/g, ''). - Free/Busy Scope: The Calendar node needs
calendar.readonlyscope. If you get a 403, re-authorize the credential with the correct scope. - Infinite Loops: If your agent replies to itself, add a filter in the "Clean Thread" function to skip emails where
fromcontains your own email address.
FAQ
Q: Why not use a pre-built scheduling tool like Calendly? A: Those tools force the other party to click a link and do the work. This agent meets people where they are (email) and handles unstructured natural language like "next Tuesday after lunch."
Q: Is the Gemini free tier reliable enough for production? A: It has rate limits (15 RPM). For personal use or a small team, it's fine. For enterprise, you'd want a paid tier. The architecture here is model-agnostic; you can swap the HTTP node to any LLM provider.
Q: How do I prevent the agent from sounding too robotic? A: Modify the system prompt in Step 7. Add context like "You are a friendly executive assistant. Use casual but professional language. Do not use the word 'slot', say 'window' or 'time'."
Q: Can I deploy this without n8n? A: Absolutely. The logic is portable. You can rewrite the flow as a Python script using FastAPI and deploy it on a free Fly.io instance. n8n just handles the OAuth and polling boilerplate for us. If you enjoy building local agents, check out our guide on running agents entirely on local open models.
Q: What if I need to negotiate complex multi-party meetings? A: That requires a state machine tracking multiple email threads. This guide is the foundation. For a deeper dive into agentic workflows that manage complex state, the FDE Coach program covers these patterns in detail.
Want to build like a Forward Deployed Engineer?
FDE Coach is a cohort-based program in frontend, backend, AWS, and AI. Build real products and get referred to 200+ hiring partners.
Explore the program