All guides

Setting Up Google Calendar via Google CLI to Your Telegram Agent

This guide walks you through building an AI assistant that lives on Telegram, has a persistent personality and memory, and can fully manage your Google Calendar. Text it from your phone like you would a real EA.

Why this beats every other setup

When most people connect an AI to their calendar, they use an MCP server — a plugin that adds calendar tools to Claude Code sessions while you're sitting at your computer coding. That's useful, but it's not a personal assistant.

This is different. What you're building:

  • Runs 24/7 in the background on your Mac
  • Has a name, a personality, and memory of your preferences and decisions
  • Is accessible from your phone via Telegram at any time
  • Manages your calendar proactively — not just reading it, but warning you about back-to-back pressure, suggesting better time slots, and asking for confirmation before touching existing events
  • Calls Google Calendar directly via CLI (no MCP server overhead, no extra running process)

The Telegram bot approach is the personal assistant. MCP just adds calendar tools to a coding session — there's no "assistant" there, just a tool. The difference is like having a real EA you can text vs. having a calendar widget open on your second monitor.

What you're building

code
Your phone (Telegram)
    |
    v
Telegram Bot API  <-->  loop.sh (polls every 5s)
                            |
                     claude --print (full tool access)
                             |
                    Agent persona + memory
                             |
                    gws CLI --> Google Calendar API

Stack:

  • Claude Code CLI (claude)
  • Telegram Bot API (free)
  • @googleworkspace/cli (gws) — npm package wrapping all Google Workspace APIs
  • macOS launchd — keeps the bot running after restarts
  • Bash scripts — glue

Part 1: Create your Telegram bot

1.1 Get a bot token from BotFather

Open Telegram and message @BotFather:

code
/newbot

Follow the prompts. Give it a name and a username (must end in bot). BotFather will give you a bot token — looks like 712..........:AAHdqTcvCH1vGWJxfSe....... Save it.

1.2 Get your Telegram Chat ID

Start a conversation with your bot (send it any message). Then run:

bash
curl -s "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates" | python3 -c "
import sys, json
data = json.load(sys.stdin)
for u in data['result']:
    msg = u.get('message', {})
    print('chat_id:', msg.get('chat', {}).get('id'))
    print('text:', msg.get('text'))
"

Note your chat ID — it's the number next to chat_id:. This locks the bot to only respond to you.

if that doesn't work you can start a conversation with @userinfobot and press /start then find the Id for your newly created personal assistant telegram bot.

Part 2: Set up the bot scripts

2.1 Create the directory structure

bash
mkdir -p ~/.claude/telegram/myassistant
mkdir -p ~/.claude/logs
mkdir -p ~/.claude/agents
mkdir -p ~/.claude/memory

2.2 Store your credentials

bash
# Create the .env file
cat > ~/.claude/telegram/.env << 'EOF'
MYASSISTANT_BOT_TOKEN="YOUR_BOT_TOKEN_HERE"
EOF

Store your chat ID:

bash
echo "YOUR_CHAT_ID_HERE" > ~/.claude/telegram/myassistant/chat_id.txt

Initialise the offset file (tracks which Telegram messages have been read):

bash
echo "0" > ~/.claude/telegram/myassistant/offset.txt

2.3 Create receive.sh

This uses long polling (timeout=30) — the curl call blocks and waits up to 30 seconds for a message to arrive, then returns instantly when one does. This eliminates the polling delay so the bot feels responsive. The --max-time 35 gives curl a hard timeout slightly longer than the Telegram timeout.

bash
cat > ~/.claude/telegram/myassistant/receive.sh << 'SCRIPT'
#!/bin/bash
source "$HOME/.claude/telegram/.env"
TOKEN="$MYASSISTANT_BOT_TOKEN"
OFFSET_FILE="$HOME/.claude/telegram/myassistant/offset.txt"
CHAT_ID_FILE="$HOME/.claude/telegram/myassistant/chat_id.txt"
ALLOWED_CHAT_ID=$(cat "$CHAT_ID_FILE")
OFFSET=$(cat "$OFFSET_FILE")

RESPONSE=$(curl -s --max-time 35 "https://api.telegram.org/bot${TOKEN}/getUpdates?offset=${OFFSET}&limit=10&timeout=30")

echo "$RESPONSE" | node -e "
const data = JSON.parse(require('fs').readFileSync('/dev/stdin', 'utf8'));
const allowedId = ${ALLOWED_CHAT_ID};
if (data.ok && data.result.length > 0) {
let maxId = parseInt(require('fs').readFileSync('${OFFSET_FILE}', 'utf8').trim()) || 0;
data.result.forEach(u => {
if (u.update_id >= maxId) maxId = u.update_id + 1;
if (u.message && u.message.text && u.message.chat.id === allowedId) {
console.log(u.message.text);
}
});
require('fs').writeFileSync('${OFFSET_FILE}', String(maxId));
}
"
SCRIPT
chmod +x ~/.claude/telegram/myassistant/receive.sh

2.4 Create send.sh

bash
cat > ~/.claude/telegram/myassistant/send.sh << 'SCRIPT'
#!/bin/bash
source "$HOME/.claude/telegram/.env"
TOKEN="$MYASSISTANT_BOT_TOKEN"
CHAT_ID=$(cat "$HOME/.claude/telegram/myassistant/chat_id.txt")
MESSAGE="$1"

curl -s "https://api.telegram.org/bot${TOKEN}/sendMessage" \
  -d "chat_id=${CHAT_ID}" \
  --data-urlencode "text=${MESSAGE}" \
  -d "parse_mode=Markdown" > /dev/null
SCRIPT
chmod +x ~/.claude/telegram/myassistant/send.sh

2.5 Create your agent personality file

This is where you define who your assistant is. Save it as ~/.claude/agents/myassistant.md.

Generic template — customise the name, personality, and context sections:

markdown
---
name: myassistant
description: Your personal AI assistant
---
# [Your Assistant Name]
You are [Name], [Your Name]'s personal assistant. You handle planning, decisions, research, scheduling, and anything else [Your Name] needs.
**Your personality:** [Describe the tone: e.g. direct and concise, warm and supportive, professional, etc.]
 Proactive — flag problems before they become issues
 Use tools rather than asking [Your Name] to do things you can do yourself
**[Your Name]'s world:** [List their main projects, job, business context]
 [Any tools or systems they use you should know about]
**How you work:** Check actual files and logs rather than guessing from memory
 Keep responses concise. Mobile-friendly — short paragraphs.
 When asked to remember something, append it to ~/.claude/memory/myassistant.md
**Learnings:** Read ~/.claude/memory/learnings/myassistant.md at the start of each conversation if it exists
 When corrected, write it there in format: ### YYYY-MM-DD - [title] / What happened: / Rule:

2.6 Create loop.sh

This is the main bot loop. Important: Uses printf to build the prompt into a temp file rather than a bash string assignment. This avoids a silent failure in macOS launchd where multi-byte characters (em dashes, smart quotes) in a double-quoted bash string cause the variable to collapse to empty.

bash
cat > ~/.claude/telegram/myassistant/loop.sh << 'SCRIPT'
#!/bin/bash
# Personal AI Assistant - Telegram Bot
# Polls Telegram, responds with full tool access + memory

BASE="$HOME/.claude/telegram/myassistant"
LOG="$HOME/.claude/logs/telegram-myassistant.log"
HISTORY="$BASE/history.txt"
source "$HOME/.claude/telegram/.env"
TOKEN="$MYASSISTANT_BOT_TOKEN"
CHAT_ID=$(cat "$BASE/chat_id.txt")
MAX_HISTORY=20

touch "$HISTORY"

# Collect all memory files into one block
collect_memory() {
  if [ -f "$HOME/.claude/memory/myassistant.md" ]; then
    echo "=== Assistant Memory ==="
    cat "$HOME/.claude/memory/myassistant.md"
    echo ""
  fi
  if [ -f "$HOME/.claude/memory/learnings/myassistant.md" ]; then
    echo "=== Learnings ==="
    cat "$HOME/.claude/memory/learnings/myassistant.md"
    echo ""
  fi
}

send_typing() {
  curl -s "https://api.telegram.org/bot${TOKEN}/sendChatAction" \
    -d "chat_id=${CHAT_ID}" \
    -d "action=typing" > /dev/null
}

send_response() {
  local text="$1"
  while [ ${#text} -gt 0 ]; do
    local chunk="${text:0:4000}"
    bash "$BASE/send.sh" "$chunk"
    text="${text:4000}"
    [ ${#text} -gt 0 ] && sleep 1
  done
}

echo "[$(date)] Bot starting" >> "$LOG"
unset CLAUDECODE

while true; do
  MESSAGES=$(bash "$BASE/receive.sh" 2>/dev/null)

  if [ -n "$MESSAGES" ]; then
    echo "[$(date)] Received: $MESSAGES" >> "$LOG"

    send_typing

    PERSONA=$(cat "$HOME/.claude/agents/myassistant.md" 2>/dev/null)
    ALL_MEMORY=$(collect_memory)
    HISTORY_CONTENT=$(tail -n $((MAX_HISTORY * 2)) "$HISTORY" 2>/dev/null)
    TODAY=$(date '+%A, %d %B %Y %H:%M %Z')

    # Write prompt to temp file (printf is encoding-safe in launchd LANG=C environment)
    PROMPT_FILE=$(mktemp /tmp/assistant_prompt.XXXXXX)
    {
      printf '%s\n\n' "$PERSONA"
      printf 'Current date/time: %s\n\n' "$TODAY"
      printf '## Memory\n%s\n\n' "$ALL_MEMORY"
      printf '## Recent Conversation\n%s\n\n' "$HISTORY_CONTENT"
      printf -- '---\nUser says via Telegram: %s\n\n---\n' "$MESSAGES"
      printf 'You are responding via Telegram. You have FULL tool access - use web search, read files, run bash commands, whatever you need. Do not say you cannot do something - try it with your tools first.\n\n'
      printf 'Keep responses concise and mobile-friendly. Short paragraphs.\n\n'
      printf 'If asked to remember something, append it to %s/.claude/memory/myassistant.md using your tools.\n' "$HOME"
    } > "$PROMPT_FILE"

    send_typing &

    RESPONSE=$(claude \
      --dangerously-skip-permissions \
      --no-session-persistence \
      --print \
      --model claude-sonnet-4-6 \
      < "$PROMPT_FILE" \
      2>>"$LOG")
    rm -f "$PROMPT_FILE"

    wait 2>/dev/null

    if [ -n "$RESPONSE" ]; then
      send_response "$RESPONSE"
      echo "[$(date)] Sent response (${#RESPONSE} chars)" >> "$LOG"

      echo "User: $MESSAGES" >> "$HISTORY"
      echo "Assistant: $RESPONSE" >> "$HISTORY"

      # Extract new memories every 20 exchanges
      LINE_COUNT=$(wc -l < "$HISTORY")
      if (( LINE_COUNT % 40 == 0 && LINE_COUNT > 0 )); then
        BATCH=$(tail -n 40 "$HISTORY")
        EXISTING_MEMORY=$(cat "$HOME/.claude/memory/myassistant.md" 2>/dev/null)
        EXTRACT_PROMPT=$(printf 'Extract long-term facts about the user from these exchanges. Output ONLY new bullet points not already in memory. If nothing new, output: NONE\n\nExchanges:\n%s\n\nExisting memory (do not duplicate):\n%s' "$BATCH" "$EXISTING_MEMORY")
        NEW_MEMORY=$(printf '%s' "$EXTRACT_PROMPT" | claude \
          --dangerously-skip-permissions \
          --no-session-persistence \
          --print \
          --model claude-haiku-4-5-20251001 \
          2>/dev/null)
        if [ -n "$NEW_MEMORY" ] && [ "$NEW_MEMORY" != "NONE" ]; then
          echo "" >> "$HOME/.claude/memory/myassistant.md"
          echo "" >> "$HOME/.claude/memory/myassistant.md"
          echo "$NEW_MEMORY" >> "$HOME/.claude/memory/myassistant.md"
          echo "[$(date)] Memory updated" >> "$LOG"
        fi
      fi
    else
      echo "[$(date)] Empty response from Claude" >> "$LOG"
      bash "$BASE/send.sh" "Sorry, I hit a snag. Try again in a moment."
    fi
  fi

  sleep 5
done
SCRIPT
chmod +x ~/.claude/telegram/myassistant/loop.sh

2.7 Set up launchd to keep it running

This makes the bot start automatically on login and restart if it crashes.

bash
cat > ~/Library/LaunchAgents/com.claude.telegram.myassistant.plist << 'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.claude.telegram.myassistant</string>
    <key>ProgramArguments</key>
    <array>
        <string>/bin/bash</string>
        <string>/Users/YOUR_USERNAME/.claude/telegram/myassistant/loop.sh</string>
    </array>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PATH</key>
        <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
    </dict>
    <key>WorkingDirectory</key>
    <string>/Users/YOUR_USERNAME/.claude</string>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/Users/YOUR_USERNAME/.claude/logs/telegram-myassistant.log</string>
    <key>StandardErrorPath</key>
    <string>/Users/YOUR_USERNAME/.claude/logs/telegram-myassistant.error.log</string>
</dict>
</plist>
PLIST

Replace YOUR_USERNAME with your actual macOS username (whoami if unsure).

Load and start it:

bash
launchctl load ~/Library/LaunchAgents/com.claude.telegram.myassistant.plist
launchctl start com.claude.telegram.myassistant

2.8 Test the basic bot

Send any message to your bot on Telegram. Check the log:

bash
tail -f ~/.claude/logs/telegram-myassistant.log

You should see Received: followed by your message, then Sent response. If you see Empty response from Claude, check the .error.log file.

Part 3: Connect Google Calendar

Why CLI beats MCP here

You could connect Google Calendar as an MCP server — that adds native calendar tools to Claude Code sessions while you're working. But there's no personal assistant there. It's just a tool available in a developer session.

The Telegram bot IS the personal assistant. When you add calendar access to it via the gws CLI, your assistant can now:

  • Tell you what's on tomorrow without you opening any app
  • Warn you that the meeting you want to add creates back-to-back pressure with your existing 3pm
  • Create events with the right colour coding based on context
  • Reschedule your week around a new deadline

All of that from a text message on your phone. That's what makes this feel like a real assistant rather than a tool.

The gws CLI approach also has no running server overhead — each calendar operation is a direct bash command that gets a token at call time and talks to the Google Calendar API. Nothing extra to maintain.

3.1 Install gws CLI

bash
npm install -g @googleworkspace/cli
gws --version

3.2 Create a Google Cloud Project and OAuth credentials

Go to console.cloud.google.com.

  1. Create a new project — click the project dropdown at the top, then "New Project". Name it something like my-assistant.
  2. Enable the Google Calendar API:
    • Search "Google Calendar API" in the search bar
    • Click it and hit "Enable"
  3. Create OAuth credentials:
    • Go to "APIs & Services" > "Credentials"
    • Click "Create Credentials" > "OAuth client ID"
    • If prompted to configure the consent screen first: choose "External", fill in the app name (anything), your email for support and developer contact, save
    • Back on credentials: Application type = "Desktop app"
    • Download the JSON file — this is your client_secret.json
  4. Add yourself as a test user (required while the app is in Testing status):
    • Go to "APIs & Services" > "OAuth consent screen"
    • Scroll to "Test users", click "Add Users", add your Google account email
    • Save

3.3 Authenticate gws

bash
gws auth setup

When prompted:

  • Select "Use existing credentials file" and provide the path to your downloaded client_secret.json
  • Select the scopes — choose Google Calendar (and any others you want)
  • A browser window will open — sign in with the Google account you added as a test user
  • After authorising, the token is stored in your OS keyring

Verify it worked:

bash
gws auth status

You should see token_valid: true and your email address.

Test a live calendar call:

bash
gws calendar calendarList list

You should get JSON back with your calendars listed. If you see a 403 insufficientPermissions error, make sure the Calendar API is enabled in your GCP project (Step 2 above) and that you added yourself as a test user.

3.4 Add calendar instructions to your agent prompt

Open ~/.claude/agents/myassistant.md and add this section at the end:

markdown
## Calendar Assistant

You manage [Your Name]'s Google Calendar. You do not just show what is there - you tell them what is going to go sideways.

### How to access the calendar

Use gws CLI via Bash for all calendar operations:

List events:
  gws calendar events list --params '{"calendarId":"primary","timeMin":"ISO_DATETIME","timeMax":"ISO_DATETIME","singleEvents":true,"orderBy":"startTime"}'

Check availability:
  gws calendar freebusy query --json '{"timeMin":"ISO_DATETIME","timeMax":"ISO_DATETIME","items":[{"id":"primary"}]}'

Create event:
  gws calendar events insert --params '{"calendarId":"primary"}' --json '{"summary":"Event Title","start":{"dateTime":"2026-03-29T10:00:00+10:00"},"end":{"dateTime":"2026-03-29T11:00:00+10:00"},"colorId":"1"}'

Quick add:
  gws calendar events quickAdd --params '{"calendarId":"primary","text":"dentist Friday 9am"}'

Update event:
  gws calendar events patch --params '{"calendarId":"primary","eventId":"EVENT_ID"}' --json '{"start":{"dateTime":"..."},"end":{"dateTime":"..."}}'

Delete event:
  gws calendar events delete --params '{"calendarId":"primary","eventId":"EVENT_ID"}'

List all calendars:
  gws calendar calendarList list

Default calendar: primary. Use ISO 8601 datetimes with your local UTC offset (check: date +%z).

### Color codes
Choose a colorId based on event type (1=lavender/default, 2=sage/green, 3=grape, 4=flamingo/red, 5=banana/yellow, 6=tangerine, 7=peacock/teal, 8=graphite, 9=blueberry, 10=basil, 11=tomato)

### Permission rules
- Reading events and checking availability: do it immediately, no confirmation needed
- Creating NEW events: do it immediately, no confirmation needed
- Modifying or deleting EXISTING events: ALWAYS confirm first. Say exactly what you are about to change and wait for a "go" or "yes" before running the command.

### Availability check logic
When checking if a time works, do not just check if it is free:
1. Check 15 minutes of buffer before and after adjacent events
2. Flag if back-to-back meetings leave no transition time
3. Warn if a new event would cut into an existing focus block
4. If the slot is borderline, suggest the next clean window instead

3.5 Restart the bot

bash
launchctl stop com.claude.telegram.myassistant
sleep 2
launchctl start com.claude.telegram.myassistant

3.6 Test it

Send these to your bot on Telegram:

  • "What's on my calendar tomorrow?" — should return your events
  • "Am I free Tuesday at 2pm?" — should check freebusy and report with context
  • "Schedule a 1-hour deep work block Monday at 9am" — should create the event immediately
  • "Move that Monday block to Wednesday" — should ask you to confirm before touching it

Common snags and how to avoid them

SymptomCauseFix
Bot starts but never respondsclaude not on PATH in launchdAdd /opt/homebrew/bin to the PATH in the plist EnvironmentVariables
Empty response from ClaudeUsually a prompt building issueCheck .error.log — if you see "Input must be provided via stdin", the prompt file is empty. Check the collect_memory function
Bot responds once then goes silentOffset file not updatingCheck receive.sh — the node script must write to the offset file
gws 403 insufficientPermissionsCalendar API not enabled or wrong scopesEnable Calendar API in GCP console, re-run gws auth setup
gws Access blocked during loginNot added as test userGo to GCP > OAuth consent screen > Test users, add your Google email
invalid_client error during authOld or wrong client_secret.jsonDelete the old OAuth client, create a new "Desktop app" client, download fresh JSON
Variable is empty in launchd but works in terminalUTF-8 chars (em dashes, smart quotes) in bash string assignment + LANG=C in launchdUse printf + temp file to build your prompt, never double-quoted multi-line bash strings with unicode
Bot feels slow (2-5s delay before it starts processing)Short polling — bot only checks for messages every few secondsUse long polling: timeout=30 in getUpdates and -max-time 35 on curl. Remove sleep from the main loop. The curl call blocks and returns the instant a message arrives

The one-shot prompt to set this up

If you want to hand this to Claude Code and have it build the whole thing for you, use this prompt:

Build me a personal AI assistant Telegram bot using Claude Code CLI on my Mac. What it does: Polls Telegram for messages from me, passes them to claude --print with a custom agent persona and memory, sends the response back. Runs 24/7 via launchd. Assistant details: - Name: [YOUR ASSISTANT NAME] - Personality: [DESCRIBE THE TONE AND ROLE] - My context: [YOUR NAME, JOB, MAIN PROJECTS] Files to create: - ~/.claude/telegram/myassistant/receive.shuses long polling: curl -s --max-time 35 "...getUpdates?offset=${OFFSET}&limit=10&timeout=30" — the curl call blocks and returns instantly when a message arrives, eliminating polling delay. Filters by my chat ID (stored in chat_id.txt), updates offset.txt, prints message text. - ~/.claude/telegram/myassistant/send.sh — sends text to my chat ID via sendMessage API. - ~/.claude/telegram/myassistant/loop.sh — main loop: no sleep between iterations — receive.sh already blocks waiting for messages, so sleep is not needed. On message: read persona from ~/.claude/agents/myassistant.md, read memory from ~/.claude/memory/myassistant.md, build prompt into a temp file using printf (NOT a bash double-quoted string assignment — this silently fails in launchd LANG=C environment), pass to claude --dangerously-skip-permissions --no-session-persistence --print --model claude-sonnet-4-6 via stdin (< $PROMPT_FILE), send response back, append exchange to history.txt, run memory extraction via claude-haiku every 20 exchanges. - ~/.claude/agents/myassistant.md — agent persona file with name, personality, instructions. - ~/Library/LaunchAgents/com.claude.telegram.myassistant.plistlaunchd plist with KeepAlive true, PATH including /opt/homebrew/bin, RunAtLoad true. Google Calendar (optional): If I ask for it, set up gws CLI (@googleworkspace/cli npm package). Use gws auth setup for OAuth. Add calendar instructions to the agent prompt: use gws calendar events list/insert/patch/delete/freebusy via Bash. Permission rules: read and create immediately, always confirm before modifying or deleting existing events. Critical implementation note: In launchd, bash runs with LANG=C (no UTF-8 locale). Do NOT assign the prompt as a multi-line double-quoted bash variable — it silently becomes empty if any unicode characters are present. Always use printf to write each section to a temp file, then pipe the file to claude via stdin. My bot token is in ~/.claude/telegram/.env as MYASSISTANT_BOT_TOKEN. My chat ID is in ~/.claude/telegram/myassistant/chat_id.txt. Test it works by loading the launchd service and checking the log at ~/.claude/logs/telegram-myassistant.log.

Learn this inside the community

The full course, templates, and the people building this, free in the Skool community.

Join the community