Claude CLI in Telegram on Windows: Replies in 0 Seconds
A bot that replies to every message within 0 seconds is not fast. It means the process behind it never ran, and the text on screen is an error from cmd.exe, not a model response.
1-minute summary
- Symptom / problem
- The bot answers in 0 seconds because the process never ran, and the agent reports sending a file that went to another chat
- Who should read
- People wiring a Telegram room to Claude Code CLI in headless mode on Windows
- Fix in short
- Fix four traps, such as cmd.exe truncating multi-line messages, and verify the process really runs and the file reaches the right chat
I wanted to handle document tasks from a group chat. The group had three members: me, one colleague, and the bot. The goal was to type a request in the group and get a PDF back in that same group, with Claude Code CLI running headless and its cwd set to the project folder. That let it read the project's rules, memory, and skill files on its own, just as it would when I was giving it instructions at my computer. I wrote the wrapper as a single Python file that ended up at 366 lines, but getting it working meant hitting four traps in one night, and in every case the symptoms on screen pointed in the wrong direction. I have written them up in the order I encountered them.
The group name, people's names, bot name, paths, and all IDs and credentials in this article are examples. The symptoms, error messages, timings, and code structure are real. This article covers only the CLI wrapper. I covered the Bot API's inbound rules, such as message queues and starting chats, separately in Bots Cannot Read Message History or Contact Someone First, and one-way outbound delivery in Sending Files to Telegram and the 50 MB Limit.
The wrapper's structure, before the problems
The wrapper uses ordinary long polling, calling getUpdates with a timeout of 25 seconds, requesting only message updates, saving the offset to a state file on every update, skipping messages from bots, and skipping chats outside the allowlist. Attached files or images are downloaded into inbox folders organized by date, and their paths are passed to Claude so it can open them itself.
# วงรอบหลักของตัวห่อ (ย่อ)
import json, os, time, urllib.parse, urllib.request
TOK = os.environ["TG_BOT_TOKEN"] # ไม่ฝังรหัสลงไฟล์
ALLOWED = {int(os.environ["TG_ROOM_ID"]): "ห้องงานเอกสาร"}
def api(method, **q):
url = "https://api.telegram.org/bot%s/%s?%s" % (TOK, method, urllib.parse.urlencode(q))
with urllib.request.urlopen(url, timeout=60) as r:
return json.load(r)
def main():
st = load_state() # {"offset": 0}
while True:
try:
r = api("getUpdates", offset=st["offset"], timeout=25,
allowed_updates=json.dumps(["message"]))
except Exception as e:
log("poll err %r" % e); time.sleep(5); continue
for u in r.get("result", []):
st["offset"] = u["update_id"] + 1
save_state(st)
m = u.get("message")
if not m or (m.get("from") or {}).get("is_bot"):
log("ข้าม: ไม่ใช่ข้อความจากคน"); continue
if m["chat"]["id"] not in ALLOWED:
log("ข้าม: ห้องที่ไม่อนุญาต"); continue
handle(m)
Those two log("ข้าม: ...") lines were not there at first, and that was lesson zero. In the first version, I required group messages to start with the word "ถาม" (ask) before the bot would reply. Then I forgot my own rule. I typed a normal message and the bot stayed silent. When I checked the log, it was empty, because skipped messages were not recorded at all. There was no way to tell from the log that someone had sent a request and it had been discarded. I changed two things at once: reply to every message in an allowed chat, and log every incoming message along with the reason for skipping it.
Every filter in a bot, whether it filters chats, people, or message prefixes, must log what it skipped and why. Otherwise, when "the bot is silent," there is no way to tell whether the message never arrived, the code broke, or I told it to discard the message myself.
Trap 1: cmd.exe truncates the message to its header
Once the bot started replying, its first response was "I don't see a message," even though I had typed a multiline request. Reading that, I could easily have blamed a poor model response or an unclear wrapper prompt. Neither was the problem.
The cause was that my first version passed the prompt as a command argument and invoked it through the shell, roughly like this:
# รุ่นแรก: ส่ง prompt ทาง argument ผ่าน cmd.exe
prompt = "ข้อความจาก %s ทาง Telegram:\n%s" % (name, text)
cmd = 'claude -p "%s"' % prompt
out = subprocess.run(cmd, shell=True, capture_output=True,
text=True, cwd=WORKSPACE).stdout
On Windows, shell=True means the entire command line is handed to cmd.exe to interpret, and cmd.exe treats a newline as the end of a command. Everything after the first line was cut off there. All Claude received was the header ข้อความจาก <ชื่อ> ทาง Telegram: and nothing else. The model was right to say it could not see a message, because there really was no message to see.
The fix was to stop passing the content as an argument and send it through stdin instead, where nothing interprets the characters along the way. Any number of newlines or quotation marks can pass through intact.
Trap 2: Replies in 0 seconds because the process never ran
After fixing the first issue, I added folder restrictions to --append-system-prompt. This text contained Windows paths with backslashes and quotation marks inside it. After that, every request typed into the group got the same reply, and the wrapper's console reported a response time of 0 seconds every time.
HH:MM:SS [ห้องงานเอกสาร] <ชื่อ> ถาม: <ข้อความที่พิมพ์>
HH:MM:SS ตอบ (0 วินาที):
The system cannot find the file specified.
The message said it could not find a file, which made it tempting to check the path to the settings file or the file I had asked it to open. In fact, the 0-second timing was a clearer clue than the message. No matter how fast the model responds, it cannot take 0 seconds. That number meant the Claude process never started, and the message was an error from cmd.exe itself, which had misinterpreted the backslashes and quotation marks in the system prompt and mangled the entire command line. My wrapper forwarded stdout straight into the group, so it looked like a bot reply.
The fix was to stop using shell=True and pass a list to Popen to invoke claude.exe directly. Each argument is one list element, with no cmd.exe splitting the command into words.
def claude_cmd():
# ห้ามใช้ shell=True: cmd.exe ตีความ backslash กับ quote ใน system prompt ผิด
return [CLAUDE, "-p",
"--input-format", "stream-json",
"--output-format", "stream-json", "--verbose",
"--model", MODEL,
"--permission-mode", "acceptEdits",
"--settings", os.path.join(HERE, "claude-settings.json"),
"--add-dir", r"C:\projects\web",
"--allowedTools", "Read", "Write", "Edit", "Glob", "Grep", "Bash",
"--append-system-prompt", SYSTEM]
One string, split by cmd.exe
A newline ends the command. Nested quotation marks and backslashes are interpreted again. The result is a truncated prompt, or a broken command that returns a shell error instead of a response.
Separate arguments, content through stdin
CLI options go directly to the program as a list. The text people type never passes through the command line. Every character stays untouched, and chat messages have no opportunity to become shell commands.
Have the wrapper time every response and always write it to the log. An unnaturally fast reply is not a model response. Suspect the process invocation layer first, before changing the prompt or configuration files.
Trap 3: A new process for every message takes 22 seconds
Once it worked correctly, I found it was slow. Every message took around 22 seconds, even a short question. Starting a new process each time meant the CLI had to reread the project's rules files and another 41 memory files (around 24 KB), then create a new cache of 30,000 to 50,000 tokens before it could start thinking about the request. Every message repeated both the time and the cost.
I changed it to keep one process running per chat, using stream-json for both input and output. I write each new message as one line of JSON to stdin, then read stdout line by line until an event arrives whose type is result.
class Session:
"""โปรเซส Claude ที่เปิดค้างไว้ต่อหนึ่งห้อง อ่านกติกากับ memory ครั้งเดียวตอนเปิด"""
procs = {}
@classmethod
def get(cls, key):
p = cls.procs.get(key)
if p and p.poll() is None:
return p
p = subprocess.Popen(claude_cmd(), cwd=WORKSPACE,
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, encoding="utf-8",
errors="replace", bufsize=1)
cls.procs[key] = p
return p
def run_claude(prompt, key):
for attempt in (1, 2): # โปรเซสตาย = เปิดใหม่ ลองซ้ำครั้งเดียว
p = Session.get(key)
msg = {"type": "user", "message": {"role": "user",
"content": [{"type": "text", "text": prompt}]}}
p.stdin.write(json.dumps(msg, ensure_ascii=False) + "\n")
p.stdin.flush()
deadline = time.time() + 1800
while time.time() < deadline:
line = p.stdout.readline()
if not line:
break # ท่อปิด แปลว่าโปรเซสตาย
try:
ev = json.loads(line)
except ValueError:
continue
if ev.get("type") == "assistant":
for c in (ev.get("message") or {}).get("content", []):
if c.get("type") == "tool_use":
log(" ใช้เครื่องมือ: %s" % c.get("name"))
elif ev.get("type") == "result":
if ev.get("is_error"):
return "ผิดพลาด: %s" % ev.get("result")
return ev.get("result") or "(ไม่มีข้อความตอบ)"
Session.close(key)
return "session หลุด ลองใหม่อีกครั้ง"
The measured time was 22 seconds for the first message in a chat (it still has to load everything once), then 6 seconds from the next message onward. A useful extra was assistant events containing tool_use. I printed the tool names to the log, which let me see what files the agent was reading or editing, or what commands it was running while I waited. That would become important evidence in the next trap.
The tradeoff with a persistent process is that the chat's context keeps accumulating in that one process, and there needs to be a recovery path if it dies midway through. I added two things: if stdout cannot be read, treat the process as dead, close it, start a new one, and retry once; and a per-message timeout of 1800 seconds for genuinely long document tasks.
Trap 4: The agent says it sent the file, but the group has nothing
The first real task I gave it was to create a PDF and send it to the group. The agent replied that it had finished and sent it, but there was no file in the group. The next question was "Why is there no PDF in the group?"
I opened the wrapper's log. There was not a single file delivery entry. Tracing through it, I found that the PDF had indeed been created. The agent had then found another Telegram delivery script on the machine, which I had written to send files to myself. That script's destination was my private chat. It called that script, got a successful result, and reported what it saw: the file had been sent. No one had said anything wrong, but the file was in the wrong place.
I removed that route entirely, keeping the agent from holding the bot credentials or calling the API itself, and defined a protocol instead: to send a file, end the response with a SEND: <path> line or place the file in the outbox folder. The wrapper always sends it to the chat specified in the configuration file. The system prompt also explicitly says not to claim a file has been sent unless that response contains a SEND line.
SEND_RE = re.compile(r"^\s*SEND:\s*(.+?)\s*$")
def deliver(out, chat, reply_to):
keep = []
for line in out.splitlines():
m = SEND_RE.match(line)
if not m:
keep.append(line); continue
fp = m.group(1).strip("\"'")
if not os.path.isabs(fp):
fp = os.path.join(WORKSPACE, fp)
if os.path.isfile(fp):
send_file(FILE_CHAT, fp, caption=os.path.basename(fp)) # log ทุกครั้ง
else:
keep.append("(ไม่พบไฟล์ %s)" % fp)
flush_outbox(FILE_CHAT) # ไฟล์ใน outbox ส่งแล้วย้ายเข้า sent/
return "\n".join(keep).strip() or "(ส่งไฟล์แล้ว)"
For actions with external effects, such as sending a file to a chat, the wrapper must perform the action and log it itself. The agent's only job is to declare its intent in a verifiable format. If the wrapper's log has no file delivery entry, the file has not been sent, whatever the response says.
After this change, the log entries became the single reliable source of evidence, with the filename, size, and API result all recorded. If someone asks where a file went, I can open the log and answer immediately without asking the agent.
Restricting permissions and separating command users from archive-only users
During the first night's testing, I launched it with --dangerously-skip-permissions so it could work without stopping to ask. That should not stay in place beyond testing, because this channel accepts instructions from a chat group. I changed it to acceptEdits mode with a settings file containing a folder-specific deny list, allowing access only to the project folder and the local website folder.
{
"permissions": {
"defaultMode": "acceptEdits",
"additionalDirectories": ["C:\\projects\\web"],
"deny": [
"Read(//C:/Users/**)",
"Read(//C:/Windows/**)",
"Read(//D:/other-project/**)",
"Edit(//C:/Users/**)",
"Edit(//D:/other-project/**)",
"Write(//C:/Users/**)",
"Write(//C:/Windows/**)",
"Write(//D:/other-project/**)",
"Bash(format:*)",
"Bash(rd /s:*)",
"Bash(rmdir /s:*)"
]
}
}
The deny list only blocks file read/write tools and commands written to match its patterns exactly. I do not consider it a complete barrier. It is a guardrail against mistakes. I wrote in more detail about controlling AI with permission to run commands in Controlling a Linux Server Safely with AI over SSH.
The more important layer is who can issue commands. There are two people in the group, and I split them into two lists in the configuration file: command_users, containing the one person allowed to issue commands, and archive_users, whose messages and files are only stored. Messages are appended to archive/messages.md, and attachments are moved to archive/<วันที่>/, with no commands run and no replies. Another rule is that when I start a message with my colleague's name, the wrapper skips it, because that is a person talking to another person, not an instruction to the bot.
| Sender in the group | What the wrapper does | Log entry |
|---|---|---|
| In command_users | Passes the message to the chat's Claude process, then replies | Request / Tool use / Reply (seconds taken) |
| In archive_users | Stores messages and files in archive | Archived, no commands run |
| Command user starts with a group member's name | Skips | Skipped (talking to each other, not a bot command) |
| Bot, or chat outside the allowlist | Skips | Skipped, with reason |
Startup script, model, and polling errors
The wrapper is launched through PowerShell in a visible console window. Closing the window stops it. Three lines need to be set before calling Python; otherwise, Thai text in the console is garbled and logs do not appear immediately.
# start_agent.ps1
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $here
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$env:PYTHONIOENCODING = 'utf-8'
$env:PYTHONUNBUFFERED = '1'
try {
& python (Join-Path $here 'agent.py')
}
finally {
Write-Host '*** agent หยุดทำงานแล้ว ***'
Read-Host 'กด Enter เพื่อปิดหน้าต่าง'
}
For the model, I started with the mid-sized one (sonnet). Its document layout work was not good enough, so I switched to the larger one (opus), which is slower and around 5 times more expensive. That decision depends on the kind of work. For short questions and answers, the mid-sized model is enough.
The polling loop encounters occasional network errors as expected. I saw three types in the actual logs: HTTPError 502, ConnectionResetError 10054, and getaddrinfo failed 11001. All are handled the same way: log the error, sleep for 5 seconds, then continue the loop, so a network interruption does not kill the wrapper.
- Log every incoming message, including skipped ones, with the reason.
- Send the text people type only through stdin, never through the command line.
- Invoke the program with a list, without shell=True on Windows.
- Time every response. For an unnaturally fast reply, suspect the process invocation layer first.
- Keep one process running per chat using stream-json, with recovery if the process dies and a timeout.
- Have the wrapper send files itself, through SEND lines or outbox, and log every delivery.
- Use acceptEdits with a folder-specific deny list instead of bypassing all permissions.
- Separate the list of people allowed to issue commands from those whose messages are only archived.
The ending was uneventful. Eight days later, I removed the wrapper from the machine's Startup and kept an icon to launch it manually when needed. Personally, I do not think an agent that accepts instructions from a chat group and can edit local files should be left running all the time when I am not there watching it. Anyone who wants to keep it running as a service can do so, but should first have a list of authorized command users, a deny list, and complete file delivery logs. If I missed anything in the explanation or made a typo, I will come back and fix it later.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first