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
Your phone (Telegram)
|
v
Telegram Bot API <--> loop.sh (polls every 5s)
|
claude --print (full tool access)
|
Agent persona + memory
|
gws CLI --> Google Calendar APIStack:
- 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:
/newbotFollow 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:
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
mkdir -p ~/.claude/telegram/myassistant
mkdir -p ~/.claude/logs
mkdir -p ~/.claude/agents
mkdir -p ~/.claude/memory2.2 Store your credentials
# Create the .env file
cat > ~/.claude/telegram/.env << 'EOF'
MYASSISTANT_BOT_TOKEN="YOUR_BOT_TOKEN_HERE"
EOFStore your chat ID:
echo "YOUR_CHAT_ID_HERE" > ~/.claude/telegram/myassistant/chat_id.txtInitialise the offset file (tracks which Telegram messages have been read):
echo "0" > ~/.claude/telegram/myassistant/offset.txt2.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.
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.sh2.4 Create send.sh
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.sh2.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:
---
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.
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.sh2.7 Set up launchd to keep it running
This makes the bot start automatically on login and restart if it crashes.
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>
PLISTReplace YOUR_USERNAME with your actual macOS username (whoami if unsure).
Load and start it:
launchctl load ~/Library/LaunchAgents/com.claude.telegram.myassistant.plist
launchctl start com.claude.telegram.myassistant2.8 Test the basic bot
Send any message to your bot on Telegram. Check the log:
tail -f ~/.claude/logs/telegram-myassistant.logYou 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
npm install -g @googleworkspace/cli
gws --version3.2 Create a Google Cloud Project and OAuth credentials
Go to console.cloud.google.com.
- Create a new project — click the project dropdown at the top, then "New Project". Name it something like
my-assistant. - Enable the Google Calendar API:
- Search "Google Calendar API" in the search bar
- Click it and hit "Enable"
- 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
- 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
gws auth setupWhen 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:
gws auth statusYou should see token_valid: true and your email address.
Test a live calendar call:
gws calendar calendarList listYou 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:
## 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 instead3.5 Restart the bot
launchctl stop com.claude.telegram.myassistant
sleep 2
launchctl start com.claude.telegram.myassistant3.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
| Symptom | Cause | Fix |
|---|---|---|
| Bot starts but never responds | claude not on PATH in launchd | Add /opt/homebrew/bin to the PATH in the plist EnvironmentVariables |
| Empty response from Claude | Usually a prompt building issue | Check .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 silent | Offset file not updating | Check receive.sh — the node script must write to the offset file |
gws 403 insufficientPermissions | Calendar API not enabled or wrong scopes | Enable Calendar API in GCP console, re-run gws auth setup |
gws Access blocked during login | Not added as test user | Go to GCP > OAuth consent screen > Test users, add your Google email |
invalid_client error during auth | Old or wrong client_secret.json | Delete the old OAuth client, create a new "Desktop app" client, download fresh JSON |
| Variable is empty in launchd but works in terminal | UTF-8 chars (em dashes, smart quotes) in bash string assignment + LANG=C in launchd | Use 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 seconds | Use 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 --printwith a custom agent persona and memory, sends the response back. Runs 24/7 vialaunchd. 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.sh— uses 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 inchat_id.txt), updatesoffset.txt, prints message text. -~/.claude/telegram/myassistant/send.sh— sends text to my chat ID viasendMessageAPI. -~/.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 usingprintf(NOT a bash double-quoted string assignment — this silently fails inlaunchdLANG=Cenvironment), pass toclaude --dangerously-skip-permissions --no-session-persistence --print --model claude-sonnet-4-6via stdin (< $PROMPT_FILE), send response back, append exchange tohistory.txt, run memory extraction viaclaude-haikuevery 20 exchanges. -~/.claude/agents/myassistant.md— agent persona file with name, personality, instructions. -~/Library/LaunchAgents/com.claude.telegram.myassistant.plist—launchdplist withKeepAlivetrue,PATHincluding/opt/homebrew/bin,RunAtLoadtrue. Google Calendar (optional): If I ask for it, set upgwsCLI (@googleworkspace/clinpm package). Usegws auth setupfor OAuth. Add calendar instructions to the agent prompt: usegws calendar events list/insert/patch/delete/freebusyvia Bash. Permission rules: read and create immediately, always confirm before modifying or deleting existing events. Critical implementation note: Inlaunchd, bash runs withLANG=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 useprintfto write each section to a temp file, then pipe the file to claude via stdin. My bot token is in~/.claude/telegram/.envasMYASSISTANT_BOT_TOKEN. My chat ID is in~/.claude/telegram/myassistant/chat_id.txt. Test it works by loading thelaunchdservice and checking the log at~/.claude/logs/telegram-myassistant.log.