
I open Telegram. There's a supergroup called Codex vs Claudius. Inside it, twenty-three forum topics. Each topic title tells me what's in it: STRC paper draft 4. CWB invoice follow-up. Habitica seed cleanup. tg-channel rate limit. Lyfar studio cover redesign. I scroll the list, and I remember everything I've been working on this week. Not what's open in my IDE. Not what's on my Notion board (there isn't one). What I actually said to my agents and what they did.
That is a dashboard. I did not build a dashboard. I built a Telegram channel.
Two bots live in it. One is Claude Code. One is Codex. Both wired into the same kernel on my Mac. Each forum topic = one persistent session belonging to one bot. State survives restarts. Resume picks up where it left off.
Two small local LLMs sit behind the bots on my Mac, both running on Ollama. One is a router: it classifies new /new commands into the right project — waffle, brain, cwb, strc, whoever owns this work. The other is an observer: after every turn it writes a one-sentence note about what happened, drops it into a JSONL, and the other bot can read it next time. Cross-bot memory.
No cloud. No SaaS. No dashboards. Just one Telegram channel and a Mac.
This post is the build.
Why Slack and Discord don't work
Slack is a corporate chat. It assumes I am at a desk, logged in, in a company workspace. Mobile app is heavy. Threads are second-class. I cannot run a bot in it without a paid workspace plus a real admin and a real OAuth flow. Discord is closer but the same problem — bots there serve "Discord communities," not "two AIs on my Mac." Neither was designed for a single human running a personal swarm of agents.
Telegram doesn't care who I am. Free bot account, free supergroup, forum topics built in. The bot library (grammY) is clean TypeScript. The phone app is in my pocket. The desktop app exists. The web app exists. The bot API is public, no permission council, no paid tier. I write /new @claude Habitica seed cleanup from anywhere, and a new topic appears with a fresh Claude session inside.
That's the contract. The phone is already there. The channel is already open. The agent rides in.
What I actually built

A Bun + TypeScript monorepo at ~/Projects/Infrastructure/tg-channel/. One shared kernel. Two daemon apps:
@super_claudecode_boton port18790, fronting theclaude-codeCLI@codexboton port18791, fronting thecodexCLI
Both supervised by launchd, both reachable from anywhere through a Cloudflare tunnel (claude.lyfar.com → :18790, codex.lyfar.com → :18791). The Telegram supergroup is Codex vs Claudius. The General topic is the control center. Every non-General topic is one session, owned by exactly one bot.
/new @claude STRC paper draft 4
→ kernel creates topic
→ router decides cwd = ~/Missions/STRC/
→ claude-code spawns with --resume <stable-id>
→ topic title becomes "STRC paper draft 4"
→ all subsequent messages in that topic go to that sessionThe session in that topic is permanent. Two days later I open the topic, type, and Claude picks up the conversation. State sits in apps/claude/state.json (per-bot) and apps/claude/state.json.channel (ownership map). Restart the daemon, state survives.
Telegram becomes a project list. The topic title is the session name. The unread badge is the "needs attention" signal. The pin icon is "this one matters this week."
The router
The router is a 572-line TypeScript module: packages/bot-kernel/src/project-router.ts. When you type /new @claude something something, it has to decide which project this session belongs to — so the daemon spawns Claude with the right working directory.
Three-stage decision:
| Stage | What | Cost |
|---|---|---|
| 1. Alias prefix | tg-channel: ..., waffle: ..., cwb: ... — instant match | $0, <1 ms |
| 2. Keyword heuristics | Score keywords against project taxonomy, must clear confidence threshold | $0, ~5 ms |
| 3. Local LLM classifier | qwen3.6:35b via Ollama at localhost:11434, returns JSON {project, confidence, title, description, reason} | $0, 1-7 s |
The LLM stage is off by default since 2026-05-17. Story below.
When the router is right, the new topic spawns inside the right working directory, with the right files in context, and Claude doesn't waste 45k cold-start tokens learning what repo you meant. When the router is wrong, you fix the topic by typing /project waffle in it and the kernel moves the cwd live. Cheap rollback.
The observer

The observer is another 467-line module: packages/bot-kernel/src/observer.ts. It runs after every turn — user message in, bot response out — and writes a small JSON record:
{
"title": "fixed observer dedup race",
"summary": "Patched bot-kernel/observer.ts to dedup on hash, added regression test.",
"changedFiles": ["packages/bot-kernel/src/observer.ts"],
"commands": ["bun test"],
"verified": ["62/62 passing"],
"openQuestions": [],
"risk": ""
}These records land in ~/.cache/tg-channel/observer/summaries.jsonl. Rotates at 2 MB, keeps two archives. Both bots read each other's recent summaries before they reply, so if Claude finished something an hour ago in another topic and you ping Codex about it now, Codex already knows what happened.
This is the cross-bot memory layer. Without it, every session is amnesic and the two bots can't coordinate on shared work. With it, "what did Claude do on the seed script yesterday?" is answerable from any topic, because the observer wrote it down.
Same model as the router: qwen3.6:35b. Same Ollama endpoint. 18-second timeout (the observer reads more context than the router does). Disabled with TG_OBSERVER=0 if I want to debug without it.
The observer is enabled by default. It paid for itself the first week.
The force reboot

2026-05-17. Saturday. I was on a walk and pinged the agent about something boring. Mac went unresponsive ten minutes later. Came home, hard-restart. Looked at logs.
Every single Telegram event was firing the router LLM. qwen3.6:35b is a 24 GB model. The router was loading it, running it for 7 seconds, freeing it, loading it again, running it, freeing it — for every nudge, every /menu click, every chat. Memory pressure stacked, swap thrashed, the OS gave up.
Fix the next morning: alias prefixes are tried first, keyword scoring covers 90% of new sessions, LLM stage falls back to off by default. TG_PROJECT_ROUTER_OLLAMA=1 re-enables it for sessions where I genuinely need semantic classification. Most of the time alias plus keywords is enough.
Lesson: local doesn't mean free. 24 GB on every event is not free. The router LLM is a tool, not a default.
The observer stayed on — it runs once per turn, not per event, and 18 seconds of qwen3.6:35b once every minute or two is fine. The router fired on every keystroke effectively. Different cost shape, different policy.
Cost
| Component | Cost |
|---|---|
| Telegram bot accounts (2 bots) | $0 |
| Supergroup with forum topics | $0 |
| Bun + TypeScript + grammY | $0 |
| Cloudflare tunnel (claude.lyfar.com, codex.lyfar.com) | $0, free tier |
qwen3.6:35b via Ollama (router + observer) | $0, local |
| launchd supervision | $0 |
| Anthropic API (Claude conversational use) | ~$2-8/month |
| Codex CLI (OpenAI subscription I already have) | $0 incremental |
| Total | ~$2-8/month |
The router and observer are the most expensive parts in compute, and they cost $0 in dollars. They cost RAM, and the price of that lesson was one Saturday afternoon.
Surface vs Bridge

Same two terms.
Surface — the Telegram channel. Codex vs Claudius. The forum topic list. The General control center. What I touch.
Bridge — the kernel, the two daemons, launchd, the Cloudflare tunnel, the router LLM, the observer LLM, the JSONL store, the state files, auth.ts gating by user ID.
The Surface is free and in my pocket. The Bridge is one weekend of TypeScript plus a launchd plist.
The pattern continues. Three surfaces now in this catalog:
- Habitica — a children's habit app, the morning standup lives in it
- Zello — a walkie-talkie radio app, voice-mode AI lives in it
- Telegram — a chat app, the channel becomes the dashboard
None of them are productivity tools. None of them were built for AI agents. All of them were already in my hand. The Bridge meets the agent where the human already is.
What this enables
I open Telegram once or twice an hour, the same way I check messages. The unread badge on a topic tells me which session has new output. The topic title tells me what it's about. The observer summary inside the topic tells me what just happened. I pick the one that matters now, type the next instruction, close the app.
There is no dashboard tab to open. No status page to load. No project board to maintain. The channel is the board. The topics are the sessions. The router is the project picker. The observer is the standup log.
What I didn't have to build: a UI, a backend, an auth system, a notification layer, a mobile client, a desktop client, a sync engine, a permissions model, a tag taxonomy, a search index. All of those exist already inside Telegram, paid for and maintained by someone else.
What I did build: a 1,800-line TypeScript kernel that points two AI agents at the same chat. That's it. The chat does the rest.
The chat box says: come here, sit down, type.
The walkie-talkie says: keep walking, I'm with you.
The Telegram channel says: I already know what you were doing yesterday.