A conversational assistant for your Google Calendar, in the terminal. Ask it what's on your schedule, book meetings, move them, cancel them — in plain English.
You: what do I have on Friday?
AI: You have one meeting on 4 September: "test", 09:30–10:30 IST,
with anubhav@example.com. Google Meet link included.
You: move it to 3pm and invite priya@example.com
AI: Moved "test" to 15:00–16:00 IST and invited priya@example.com.
Built with LangGraph for the agent loop, Groq for inference, and the Google Calendar API — running on Bun.
- Read — list events in any time range, with free-text search
- Create — book events with attendees, location, and an auto-generated Google Meet link
- Update — patch an existing event without disturbing fields you didn't mention
- Delete — remove events, with the agent confirming first
- Multi-turn memory — follow-ups like "move it to 3pm" resolve against earlier turns
- Date grounding — the agent is told the current date and timezone on every call, so "next Tuesday" means what you think it means
- Bun 1.3+
- A Groq API key
- A Google Cloud project with the Calendar API enabled
git clone <your-repo-url>
cd google-calendar-agent
bun install-
In the Google Cloud Console, create or select a project.
-
Enable the Google Calendar API for that project.
-
Configure the OAuth consent screen. While it's in Testing, add your own Google account under Test users — otherwise the sign-in will be refused.
-
Under Credentials, create an OAuth client ID of type Web application, and add this authorised redirect URI:
http://localhost:3600/callback
cp .env.example .envFill in .env:
| Variable | Description |
|---|---|
GROQ_API_KEY |
From the Groq console |
GOOGLE_CLIENT_ID |
From your OAuth client |
GOOGLE_CLIENT_SECRET |
From your OAuth client |
GOOGLE_CALLBACK_URL |
http://localhost:3600/callback |
Bun loads .env automatically — no dotenv needed.
bun run authOpen http://localhost:3600/auth, grant access, and the tokens are written to token.json
in the project root. Stop the server once you see the success page — this is a one-time step,
and the refresh token keeps working afterwards.
token.jsongrants full read/write access to your calendar. It's gitignored; keep it that way.
bun run devWelcome to personal assistant. Type 'bye' to leave.
You: am I free tomorrow afternoon?
Type bye, exit, or quit to leave.
The agent is a LangGraph state machine with two nodes. assistant calls the model; if the
reply contains tool calls, control moves to tools, which executes them and loops back so the
model can read the results. When there are no tool calls left, the answer is returned.
┌───────────┐ tool calls ┌───────┐
│ assistant │ ────────────► │ tools │
└───────────┘ ◄──────────── └───────┘
│ results
│ no tool calls
▼
done
A MemorySaver checkpointer keeps each conversation's history under a thread id, so a
follow-up question can refer back to what was said earlier.
Every call prepends a freshly built system prompt carrying the current date and IANA timezone. Language models have no clock — without this, a request for "this year" gets resolved against the model's training data rather than today.
Every run is traced to Langfuse — each graph node, model call and tool call, with inputs, outputs, latency and token counts.
Set the Langfuse variables in .env (self-hosted, or a
cloud project):
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_BASE_URL=http://localhost:3000src/observability.ts registers an OpenTelemetry tracer provider with Langfuse's span
processor, and the CLI passes a CallbackHandler on every invocation. Each conversation gets
one sessionId, shared with the checkpointer's thread id, so a trace lines up with the
conversation it came from.
The agent still answers if the Langfuse keys are missing, but the SDK warns on startup and the export fails noisily on exit, so set the keys or remove the import.
index.ts CLI entrypoint — the readline loop
src/
agent/
graph.ts LangGraph wiring, system prompt, checkpointer
llm.ts Groq model with the tools bound
tools/
get-events.ts events.list
create-event.ts events.insert, with a Meet link
update-event.ts events.patch
delete-event.ts events.delete
google/
oauth.ts OAuth client, consent URL, code exchange
tokens.ts reads and writes token.json
calendar.ts authenticated Calendar client
observability.ts Langfuse tracing via OpenTelemetry
server.ts one-shot OAuth callback server
The split is deliberate: agent/ knows nothing about Google, and google/ knows nothing
about LangGraph. Swapping the model or adding a second calendar provider touches one side only.
| Command | What it does |
|---|---|
bun run dev |
Start the conversational CLI |
bun run auth |
Start the OAuth server for the one-time sign-in |
bun run typecheck |
Type-check without emitting |
A few decisions that aren't obvious from the code:
The Calendar client is a function, not a shared constant. Building it at module scope
would need top-level await, so importing any tool would throw before the OAuth server could
run. getCalendar() defers that to the moment a tool is called, where the failure becomes a
message the agent can relay.
Updates use events.patch, not events.update. update replaces the whole event, which
silently drops the Meet link, attendees, and description on every edit. The patch body is
assembled key by key, because a field that is present but undefined clears it.
get-events sets singleEvents: true. The API rejects orderBy: "startTime" without it,
since a recurring event has no single start time to sort by.
Tools return real error messages. A tool that swallows a failure and returns a generic string leaves the model unable to explain what went wrong or retry a fixable request.
Optional fields are genuinely optional. A required field the user didn't specify forces the model to invent a value — a required search term, for instance, silently filters out the events you were asking about.
- Single user — one
token.jsonin the project root - Conversation history is in memory, so it resets when the process exits
- Reads and writes the
primarycalendar only - No test suite yet
MIT