Part 1 — Manual prep (5 min)
- Open Telegram, search @BotFather, send
/newbot. Pick a name + username. Copy the bot token (looks like1234567890:AAGxxxx...). - DM your new bot — send any message (e.g.
hi). This is required so the bot can see your chat. - Get your chat ID: in your terminal, replace TOKEN below and run:
bash
curl -s "https://api.telegram.org/botTOKEN/getUpdates" | python3 -m json.tool | grep '"id"' | head -2The first "id" under "chat" is your chat ID — a positive number (e.g. 7907013802). Copy it.
You now have BOT_TOKEN and CHAT_ID.
Part 2 — Paste this into Claude Code
code
I want a personal Telegram bot that lets me chat with Claude from my phone.
Headless setup — no tmux, no inline buttons, no topics. Just DM the bot, it
calls Claude in --print mode and replies. Identical pattern to Daniel Poon's
Watson setup.
My values:
BOT_TOKEN=PASTE_HERE
CHAT_ID=PASTE_HERE
AGENT_NAME=jarvis # rename if you want; lowercase, no spaces
Execute every step in order. Show me the output of each verification command
before moving on.
================================================================
STEP 1 — Sanity check
================================================================
Run:
claude --version
which claude
which node
uname -m
If `claude` isn't found, stop and tell me to install Claude Code first.
Note whether this is arm64 (Apple Silicon, brew prefix /opt/homebrew) or
x86_64 (Intel, /usr/local) — the LaunchAgent needs the right PATH.
================================================================
STEP 2 — Create directory layout
================================================================
mkdir -p ~/.claude/telegram/${AGENT_NAME}
mkdir -p ~/.claude/agents ~/.claude/memory ~/.claude/logs
================================================================
STEP 3 — Write the credentials file
================================================================
Create ~/.claude/telegram/.env with mode 600:
${AGENT_NAME^^}_BOT_TOKEN=<BOT_TOKEN>
(Uppercase variable name so JARVIS_BOT_TOKEN, WATSON_BOT_TOKEN etc. all
coexist if you add more agents later.)
Then: chmod 600 ~/.claude/telegram/.env
Write the chat ID:
echo "<CHAT_ID>" > ~/.claude/telegram/${AGENT_NAME}/chat_id.txt
echo "0" > ~/.claude/telegram/${AGENT_NAME}/offset.txt
touch ~/.claude/telegram/${AGENT_NAME}/history.txt
================================================================
STEP 4 — Write a basic agent persona
================================================================
Create ~/.claude/agents/${AGENT_NAME}.md:
# ${AGENT_NAME^}
You are ${AGENT_NAME^}, a personal AI assistant that chats with me via
Telegram. Be concise, direct, and useful. No filler, no emoji, no
sycophancy. If you do not know something, say so. If a task needs a tool
(web search, file read, bash, code), use it — do not refuse upfront.
(Edit later to match your style.)
================================================================
STEP 5 — Write receive.sh
================================================================
Create ~/.claude/telegram/${AGENT_NAME}/receive.sh:
#!/bin/bash
source "$HOME/.claude/telegram/.env"
TOKEN="${${AGENT_NAME^^}_BOT_TOKEN}"
OFFSET_FILE="$HOME/.claude/telegram/${AGENT_NAME}/offset.txt"
ALLOWED_CHAT_ID=$(cat "$HOME/.claude/telegram/${AGENT_NAME}/chat_id.txt")
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));
}
"
(Resolve the bash variable substitution at write time — i.e. produce literal
JARVIS_BOT_TOKEN in the file, not ${AGENT_NAME^^}_BOT_TOKEN.)
chmod +x receive.sh
================================================================
STEP 6 — Write send.sh
================================================================
Create ~/.claude/telegram/${AGENT_NAME}/send.sh:
#!/bin/bash
source "$HOME/.claude/telegram/.env"
TOKEN="${${AGENT_NAME^^}_BOT_TOKEN}"
CHAT_ID=$(cat "$HOME/.claude/telegram/${AGENT_NAME}/chat_id.txt")
MESSAGE="$1"
RESPONSE=$(curl -s "https://api.telegram.org/bot${TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
--data-urlencode "text=${MESSAGE}" \
-d "parse_mode=Markdown")
if echo "$RESPONSE" | grep -q '"ok":false'; then
curl -s "https://api.telegram.org/bot${TOKEN}/sendMessage" \
-d "chat_id=${CHAT_ID}" \
--data-urlencode "text=${MESSAGE}" >/dev/null
fi
chmod +x send.sh
================================================================
STEP 7 — Write loop.sh (the main bot)
================================================================
Create ~/.claude/telegram/${AGENT_NAME}/loop.sh with this exact content,
substituting AGENT_NAME wherever it appears. This is the polling loop that
runs forever, calls Claude headlessly per message, handles timeouts,
chunks long replies, and extracts long-term memory every 20 exchanges:
#!/bin/bash
AGENT="${AGENT_NAME}"
BASE="$HOME/.claude/telegram/$AGENT"
LOG="$HOME/.claude/logs/telegram-$AGENT.log"
HISTORY="$BASE/history.txt"
source "$HOME/.claude/telegram/.env"
TOKEN_VAR="${${AGENT_NAME^^}_BOT_TOKEN}"
TOKEN="$TOKEN_VAR"
CHAT_ID=$(cat "$BASE/chat_id.txt")
MAX_HISTORY=20
touch "$HISTORY"
PIDFILE="$BASE/.loop.pid"
if [ -f "$PIDFILE" ]; then
OLD_PID=$(cat "$PIDFILE" 2>/dev/null)
if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null && [ "$OLD_PID" != "$$" ]; then
echo "[$(date)] Already running (pid=$OLD_PID), exiting" >> "$LOG"
exit 0
fi
fi
echo $$ > "$PIDFILE"
trap 'rm -f "$PIDFILE"' EXIT
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
bash "$BASE/send.sh" "${text:0:4000}"
text="${text:4000}"
[ ${#text} -gt 0 ] && sleep 1
done
}
echo "[$(date)] $AGENT bot starting" >> "$LOG"
unset CLAUDECODE
while true; do
MESSAGES=$(bash "$BASE/receive.sh" 2>/dev/null)
if [ -z "$MESSAGES" ]; then continue; fi
echo "[$(date)] Received: $MESSAGES" >> "$LOG"
send_typing
PERSONA=$(cat "$HOME/.claude/agents/$AGENT.md" 2>/dev/null)
MEMORY=$(cat "$HOME/.claude/memory/$AGENT.md" 2>/dev/null)
HIST=$(tail -n $((MAX_HISTORY * 2)) "$HISTORY" 2>/dev/null)
TODAY=$(date '+%A, %d %B %Y %H:%M %Z')
PROMPT_FILE=$(mktemp /tmp/${AGENT}_prompt.XXXXXX)
{
printf '%s\n\n' "$PERSONA"
printf 'Current date/time: %s\n\n' "$TODAY"
printf '## Memory\n%s\n\n' "$MEMORY"
printf '## Recent conversation\n%s\n\n' "$HIST"
printf -- '---\nUser says via Telegram: %s\n\n---\n' "$MESSAGES"
printf 'Respond as %s. You have full tool access — use it.\n\n' "$AGENT"
printf '## HEADLESS — no stdin, no human-in-the-loop\n'
printf 'Never run blocking commands: OAuth flows, sudo password prompts, vim/nano/less, REPLs, foreground servers. If a tool needs auth, tell the user what to run in their own terminal — do not retry.\n\n'
printf 'Keep replies concise and mobile-friendly. Short paragraphs. Markdown OK.\n\n'
printf 'If asked to remember something, append it to %s/.claude/memory/%s.md.\n' "$HOME" "$AGENT"
} > "$PROMPT_FILE"
send_typing &
OUT_FILE=$(mktemp /tmp/${AGENT}_out.XXXXXX)
claude --dangerously-skip-permissions --no-session-persistence --print \
--model claude-sonnet-4-6 < "$PROMPT_FILE" > "$OUT_FILE" 2>>"$LOG" &
CLAUDE_PID=$!
WAITED=0; TIMED_OUT=0
while kill -0 "$CLAUDE_PID" 2>/dev/null; do
if (( WAITED >= 300 )); then
kill -KILL "$CLAUDE_PID" 2>/dev/null; TIMED_OUT=1; break
fi
sleep 2; WAITED=$((WAITED + 2))
done
wait "$CLAUDE_PID" 2>/dev/null
RESPONSE=$(cat "$OUT_FILE")
rm -f "$OUT_FILE" "$PROMPT_FILE"
wait 2>/dev/null
if [ -n "$RESPONSE" ]; then
send_response "$RESPONSE"
echo "User: $MESSAGES" >> "$HISTORY"
echo "$AGENT: $RESPONSE" >> "$HISTORY"
elif [ "$TIMED_OUT" = "1" ]; then
bash "$BASE/send.sh" "Hung after 5 min — try again."
else
bash "$BASE/send.sh" "Hit a snag. Try again."
fi
done
chmod +x loop.sh
Create empty memory file:
touch ~/.claude/memory/${AGENT_NAME}.md
================================================================
STEP 8 — Write the LaunchAgent plist
================================================================
Detect brew prefix from STEP 1: arm64 → /opt/homebrew/bin, x86_64 →
/usr/local/bin. Get username from `whoami`.
Create ~/Library/LaunchAgents/com.claude.telegram.${AGENT_NAME}.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.${AGENT_NAME}</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/caffeinate</string>
<string>-dimsu</string>
<string>/bin/bash</string>
<string>/Users/USERNAME/.claude/telegram/${AGENT_NAME}/loop.sh</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>BREW_PREFIX:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>WorkingDirectory</key>
<string>/Users/USERNAME/.claude</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ProcessType</key><string>Interactive</string>
<key>StandardOutPath</key>
<string>/Users/USERNAME/.claude/logs/telegram-${AGENT_NAME}.log</string>
<key>StandardErrorPath</key>
<string>/Users/USERNAME/.claude/logs/telegram-${AGENT_NAME}.error.log</string>
</dict>
</plist>
(The caffeinate wrapper prevents sleep/idle from suspending the bot —
this is the fix for the "stalls when computer logged off" problem.)
================================================================
STEP 9 — Load and verify
================================================================
launchctl unload ~/Library/LaunchAgents/com.claude.telegram.${AGENT_NAME}.plist 2>/dev/null
launchctl load ~/Library/LaunchAgents/com.claude.telegram.${AGENT_NAME}.plist
sleep 3
launchctl list | grep com.claude.telegram.${AGENT_NAME}
tail -20 ~/.claude/logs/telegram-${AGENT_NAME}.log
The launchctl line should show a PID (not "-"). The log should say
"$AGENT bot starting".
================================================================
STEP 10 — Sleep / lock survival
================================================================
Ask me first before running this — it's a system-wide power setting:
sudo pmset -c sleep 0 disksleep 0 powernap 1 tcpkeepalive 1
This stops the Mac from sleeping on AC power so the bot survives lid-close
and idle. The caffeinate wrapper in the plist already covers the rest.
Note: macOS LaunchAgents stop on FULL user logout (not lock screen, not
sleep). To survive logout you need a LaunchDaemon at /Library/LaunchDaemons
running as your user — tell me if you want that, it's a separate setup.
================================================================
STEP 11 — Smoke test
================================================================
DM your bot from your phone with: "hello, who are you?"
Within 10 seconds you should see the typing indicator, then a reply.
Tail the log:
tail -f ~/.claude/logs/telegram-${AGENT_NAME}.log
Tell me when this is done and what you see.