All guides

Setting Up Telegram to Chat to Claude Code

Part 1 — Manual prep (5 min)

  1. Open Telegram, search @BotFather, send /newbot. Pick a name + username. Copy the bot token (looks like 1234567890:AAGxxxx...).
  2. DM your new bot — send any message (e.g. hi). This is required so the bot can see your chat.
  3. 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 -2

The 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.

Learn this inside the community

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

Join the community