One process, four messengers, one SQLite file
Every "put an LLM in your chat app" tutorial reaches for the same stack: a webhook endpoint, a tunnel or a cloud function to receive it, a queue because the model is slower than the HTTP timeout, and a managed database because now you have a server. Telechat has none of those, runs on a laptop that sleeps, and speaks four platforms at once.
This is what it does instead, and what that trade actually costs.
The shape
Telegram ─┐
WhatsApp ─┤ ┌─ claude CLI (subprocess)
Slack ─┼─→ adapter ─→ claude_core.ask_* ────┼─ Anthropic API
Web chat ─┘ │ └─ claude-code-sdk
↓
store.py ──→ ~/.telechat/bot.db (SQLite, WAL)
One process. Each adapter owns its platform's quirks — Telegram's inline keyboards, Slack's thread semantics, WhatsApp's complete lack of interactivity — and then hands a plain string to the same invocation layer. Behind that sits one SQLite file holding everything: turn history, sessions, memories, the knowledge base, costs, bridge state.
Which platforms start is one environment variable:
BOT_MODE=telegram # the default
BOT_MODE=telegram,slack # a comma-separated list
BOT_MODE=all # all four
Because they share a store, they share your conversation. A memory you saved from Telegram is loaded when you ask from Slack; a session you started at your desk in the web chat is on the list when you pick one up from your phone. That is only true because there is exactly one process and exactly one database, which is the entire argument for this design.
Nobody needs to reach you
The webhook is the piece that drags everything else in. Accept an inbound HTTP request and you need a public URL, TLS, and a reply within the platform's timeout — which an LLM turn routinely blows past, so you need a queue, so you need somewhere for the queue to live.
Telechat never accepts an inbound connection from a platform:
- Telegram — long polling via
python-telegram-bot. - WhatsApp — Green API's free tier, polled over plain HTTPS.
- Slack — Socket Mode, an outbound WebSocket the app opens itself.
- Web chat — a local aiohttp server bound to
127.0.0.1.
All four are outbound. There is no inbound firewall rule, no tunnel, no certificate to renew, and nothing to expose. Telechat works behind NAT, on hotel Wi-Fi, on a corporate network, and on a laptop that closes — when it wakes, polling resumes and the backlog arrives. A turn that takes four minutes is a turn that takes four minutes; nothing is timing it out.
The cost is real and worth stating: polling has latency and it burns a little idle CPU forever. For a personal bot answering you, seconds of latency are invisible and the CPU is free. For a bot serving ten thousand users it would be the wrong call. This is a single-operator tool, and nearly every design decision below follows from that sentence.
Deliberately mixed concurrency
The obvious criticism of one process is that one blocking library ruins it. Two of these libraries are blocking, which is why the concurrency model is mixed on purpose rather than uniform on principle:
| Platform | How it runs | Why |
|---|---|---|
| Telegram | Main asyncio loop | python-telegram-bot is async, and it is the primary adapter |
| Web chat | Task on the same loop | aiohttp |
| Daemon thread | Green API polling is a blocking loop | |
| Slack | Daemon thread | slack_bolt Socket Mode is blocking |
| Health | Daemon thread | Must answer even when the loop is busy |
Wrapping the blocking clients in an executor to pretend everything is async would have bought a tidier diagram and nothing else. The honest version is a table you can read.
It does have a consequence, and it is the one that matters: the store is reached from several threads, so it is written to be thread-safe rather than loop-affine. Three properties of that layer are load-bearing, and each of them was a bug before it was a property.
1. One writer thread, whole writes
All writes go through a queue drained by a single writer thread and batched into transactions. A write operation carries every statement of one logical change, so a multi-statement write like "save this turn" can never half-apply — you never end up with the message stored and the token count lost. Transient lock and busy failures retry; permanent failures are logged with the offending SQL rather than swallowed.
2. A full queue pushes back
The first version, when the queue filled, let the caller write synchronously instead of waiting. That is the intuitive fallback and it is wrong: it lets a late write overtake queued earlier ones, so under load the database gets writes in an order that never happened. A full queue now applies back-pressure. Slow is a fine failure mode; reordered is not.
3. Reads wait for the writes they depend on
Asynchronous writes create a read-your-own-writes problem immediately: send a message, and the next turn assembles history that does not yet contain it. History reads wait for the writes they depend on to commit, so the conversation is consistent from the only vantage point that matters — the person typing.
All three are visible in production. /health reports queue_depth, retries, failures and sync_fallbacks alongside whether the writer thread is alive. A dead writer with a live queue means every write is going through the synchronous path, which returns 503 — a state that used to be invisible right up until data went missing.
Two ways to reach Claude
The invocation layer offers CLI mode and API mode, and the choice is more consequential than it looks.
CLI mode spawns the claude binary with --output-format stream-json and reads the stream line by line, turning tool-use and text events into progress callbacks — which is how a chat can show what the agent is doing rather than a spinner. It uses your existing Claude subscription: no API key, no per-token bill. It also means the bot inherits your Claude authentication and your filesystem access, which is a capability, not a footnote. It is why the allowlist should have one entry and why SECURITY.md states the trust boundary instead of burying it.
API mode takes an ANTHROPIC_API_KEY and is what the Docker image runs, because a container has no host Claude authentication to inherit. You pay per token, so the bot tracks spend and enforces daily and monthly ceilings with alerts before you hit them.
Optional features — voice transcription, image and music generation, MCP, browser automation, document extraction — are each imported inside a try/except ImportError and expose an is_available(). Install the core dependencies and the bot starts; features degrade one at a time instead of the process failing to boot because you skipped an extra you never wanted.
The part that is boring on purpose
Two operational details do more for daily use than any feature.
Starting the bot replaces the one already running. Telegram's API allows a single poller per token; a second one produces 409s until you find the first. So startup terminates the previous instance — but every candidate is checked against its own argv first. A process that merely mentions telechat — your editor, a grep, a test run — is not a telechat process. That check exists because an earlier version killed a test run, and "manage the process" and "kill anything matching a string" are very different programs.
It runs detached. telechat starts a background service that survives closing the terminal, with stop, restart, status and logs around it, launchd or systemd installation for boot, and a watchdog that restarts a wedged process. A personal assistant you have to remember to restart is one you stop using in a week.
What this architecture is bad at
Every property above is the same trade seen from a different angle, so the limits are predictable:
- One machine. No horizontal scale, and if the laptop is off, the bot is off.
- One tenant. The allowlist takes several IDs, but everyone on it shares one Claude authentication, one working directory and one permission ceiling. There is no per-user isolation, and adding it would mean a different program.
- Uneven adapters. Telegram gets inline buttons, photos, files and the Desktop bridge. WhatsApp and Slack carry the core chat loop. That asymmetry is honest — the platforms are asymmetric.
- Polling latency. Seconds, not milliseconds.
What you get for it: no server, no vendor, no account, no per-token bill in CLI mode, and a database you can open with sqlite3 and read. For a tool that exists to answer one person, that is the better side of the trade.
Run it yourself
Four platforms, one command, nothing to deploy.
npm install -g telechat && telechat init