RemoClaw is a single-process async Python bot that:
- Receives messages from Telegram via long polling (
python-telegram-bot) - Routes messages to a per-thread CLI subprocess (persistent PTY session when possible)
- Streams subprocess stdout back to Telegram with progressive message edits
- Formats final response as Telegram HTML
βββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββββ
β Telegram User βββββββΊβ Telegram Bot API (long polling) β
βββββββββββββββββββ ββββββββββββββββββββ¬ββββββββββββββββββββββββ
β
βββββββββββΌβββββββββββ
β remoclaw.py β
β (handlers + auth) β
βββββββββββ¬βββββββββββ
β
βββββββββββΌβββββββββββ
β cli_runner.py β
β (CliRunner) β
β β
β Per-thread state: β
β - _sessions β
β - _locks β
β - _exit_codes β
βββββββββββ¬βββββββββββ
β
βββββββββββΌβββββββββββ
β cli_providers/ β
β (pluggable) β
β β
β kiro.py β
β claude.py β
β gemini.py β
β codex.py β
βββββββββββ¬βββββββββββ
β
βββββββββββΌβββββββββββ
β PTY subprocess β
β (interactive) β
β β
β kiro-cli chat β
β claude -p ... β
β gemini -p ... β
β codex exec ... β
ββββββββββββββββββββββ
1. User sends message to Telegram bot
2. python-telegram-bot polls getUpdates β invokes handle_message
3. @authorized decorator checks update.effective_user.id against whitelist
4. _execute_and_reply extracts thread_id from message_thread_id
5. CliRunner.execute_stream(prompt, thread_id=X):
a. Acquires per-thread asyncio.Lock (serializes same-thread messages)
b. Gets or creates _PtySession for this thread
- If new: forks PTY via pty.fork(), waits for !> prompt
- If existing: reuses alive session
c. Writes prompt + \n to PTY fd
d. Reads PTY output via select.select() (runs in thread executor)
e. Yields chunks until next !> prompt marker
6. Bot edits streaming message every 1.5s with preview
7. Background typing keepalive task sends ChatAction.TYPING every 4s
8. After stream ends:
- format_output() strips ANSI, extracts response, converts MD β HTML
- split_message() breaks into 4096-char chunks
- Sends final messages via reply_text(parse_mode=HTML)
Each CLI is a driver file in cli_providers/ implementing CliProvider ABC:
provider_idβ unique key used in.env CLI_PROVIDERbuild_args(prompt, model, resume)β CLI command constructionbuild_env(base_env)β provider-specific env varsbuild_interactive_args(model)β optional, enables PTY sessionsresponse_markerβ stdout prefix that separates tool noise from response
Registry in cli_providers/registry.py auto-discovers via pkgutil.iter_modules() on first access. Adding a new provider = creating one file.
CliRunner maintains per-thread_id dictionaries:
_sessions: dict[int | None, _PtySession] # one PTY per thread
_locks: dict[int | None, asyncio.Lock] # sequential per thread
_exit_codes: dict[int | None, int] # last exit per threadthread_id comes from Telegram's message_thread_id (forum topic). None represents main chat. Different threads run parallel CLI subprocesses; same-thread messages queue via lock.
Cold-starting kiro-cli chat --no-interactive takes ~6-10s (MCP init, session setup). To avoid this per-message cost, RemoClaw spawns an interactive CLI via pty.fork() and keeps it alive:
- Write prompts to PTY fd via
os.write() - Read responses via
select.select()+os.read()(runs in executor thread) - Detect response completion by matching
!>prompt regex - Subsequent messages reuse the session β ~3s per message
Falls back to --no-interactive one-shot if provider doesn't implement build_interactive_args().
Telegram API limits message edits to ~30/min/chat. RemoClaw:
- Sends one initial "β³ Connecting..." message
- Buffers stream output in
preview_buffer - Edits message with latest preview every 1.5s (
_STREAM_UPDATE_INTERVAL) - Truncates preview to 3000 chars, keeps last N lines
- Deletes preview message on completion, sends final formatted response
Two concurrent tasks protect against stuck processes and user UX:
- Idle watchdog in
cli_runner.py: warns user every 30s if no PTY output, kills on global timeout (600s default, configurable) - Typing keepalive in
remoclaw.py: backgroundasyncio.TasksendsChatAction.TYPINGevery 4s, independent of stream loop
- Loads config via
Config.from_env() - Builds
python-telegram-botApplicationwithconcurrent_updates(True) - Registers command handlers:
/start /help /model /skills /status /cancel /new /resume - Registers BMAD regex handler (
^/bmad_\w+) that converts underscores β hyphens - Registers free-form text handler
_execute_and_replyis the core streaming loop@authorizeddecorator gates all handlers onALLOWED_USER_IDS
- Creates
CliProviderinstance from config execute_stream()is the primary entry β async generator yielding output chunks_PtySessionwraps pid/fd pair, tracks aliveness viaos.kill(pid, 0)_get_or_create_session()manages session lifecycle_stream_pty()/_stream_non_interactive()are the two execution pathscancel(thread_id)kills specific thread or all
- Immutable frozen
@dataclassloaded from.envviapython-dotenv - Validates required env vars (raises
ValueErroron missing) - Falls back through multiple env var names for API keys:
CLI_API_KEYβKIRO_API_KEYβANTHROPIC_API_KEYβGEMINI_API_KEYβOPENAI_API_KEY
Pipeline: format_output(text) = strip_ansi β extract_final_response β markdown_to_telegram_html
strip_ansi()β comprehensive regex for CSI/OSC/charset-selection/bare-CSI sequencesextract_final_response()β finds the>marker (for Kiro), strips tool invocations, thinking indicators, credits footermarkdown_to_telegram_html()β converts MD headings/tables/code/lists to Telegram HTMLsplit_message()β splits long messages at paragraph β line β space β hard-cut boundaries
base.pyβCliProviderABC with required/optional methodsregistry.pyβ auto-discovery viapkgutil.iter_modules()__init__.pyβ re-exports public APIkiro.pyβ Kiro CLI (interactive mode supported, has response_marker)claude.pyβ Claude Code (-pflag)gemini.pyβ Gemini CLI (-pflag, no sandbox)codex.pyβ OpenAI Codex (execsubcommand, full-auto)
- Event loop: single asyncio event loop per Python process
- concurrent_updates(True) in
python-telegram-botallows handlers to run concurrently - Per-thread locks:
asyncio.Lockperthread_idensures same-thread messages queue - Different threads: fully parallel β separate PTY subprocesses
- Blocking I/O: PTY
select.select()andos.read()run inloop.run_in_executor()to avoid blocking event loop
- Whitelist auth:
ALLOWED_USER_IDSin.env;@authorizeddecorator rejects others - Local login: CLIs authenticate via their own mechanism (browser OAuth), RemoClaw doesn't handle credentials
- API key fallback: optional env vars for headless/SSH machines without browser
- Secrets:
.envis gitignored;.env.exampleprovides template - Tool trust:
CLI_TRUST_ALL_TOOLS=trueby default β user controls this per-provider
ValueErroron missing required env vars β bot fails to start with clear messageFileNotFoundErrorwhen CLI binary missing β user-facing error messageasyncio.TimeoutErrorin subprocess β kills process, reports timeout- HTML parse errors β fallback to plain text via regex strip of tags
- PTY
BrokenPipeErrorβ kills session, prompts user to retry
| Scenario | Latency |
|---|---|
| First message (cold start, PTY init) | ~6-10s |
| Subsequent messages (warm PTY) | ~3-4s (depends on model) |
| Message edit during stream | ~1.5s interval |
| Typing indicator | Every 4s |
| Global timeout | 600s (configurable) |
| Idle warning | Every 30s after first idle period |
Current state: No automated tests exist. Manual testing via Telegram.
Recommended future additions:
- Unit tests for
message_utils.py(pure functions, easy to test) - Mock
CliProviderimplementations forcli_runner.pytests - Integration tests with a fake Telegram Bot API server
RemoClaw is designed as a long-running process on a single machine:
- No external dependencies (database, message queue, cache)
- State is in-memory (thread sessions reset on restart)
- Single bot instance per Telegram token (409 Conflict otherwise)
- PTY sessions require POSIX β Windows uses non-interactive fallback
See Deployment Guide for production setup.
- Single
PROJECT_DIRper bot instance (no/projectswitch command) - In-memory thread sessions reset on restart
- Interactive PTY mode requires POSIX (macOS/Linux); Windows falls back to non-interactive
- Telegram 4096-char message limit (auto-split, but breaks long code blocks)
- Only one bot instance can poll the same token at a time