OpenClaw
Multi-agent bridge that lets a single Claude session speak across Telegram and Discord threads without losing context.
- 1,243
- 94%
- 2 Telegram channels + 3 Discord servers
- 2.1s
The Problem
My digital life is split. I collaborate with my dev team on Discord, but my closest friends and family live in Telegram groups. This bifurcation creates a constant, low-grade friction, especially when I want to involve an AI assistant. I'd find myself asking Claude a complex question about a project in a Discord channel, getting a detailed answer, and then wanting to share a summary with a friend on Telegram. The only way was the "copy-paste dance"—a clumsy, manual process that completely severs the conversational context.
If a friend asked a follow-up question, the AI on Telegram had no memory of the original discussion from Discord. I was acting as a human message bus, manually stitching together conversations. This wasn't just inefficient; it felt fundamentally wrong. An intelligent agent should be able to follow a logical thread of conversation, regardless of the chat client I'm using. I wanted a single, continuous Claude session that could seamlessly bridge these two worlds.
The Constraint
Before writing a line of code, I set three hard constraints to avoid building a brittle, one-off solution:
-
One Claude session per logical conversation, not per chat platform. This was the core thesis. A conversation about "Project Phoenix" should have one unified context, whether a message comes from the
#phoenixDiscord channel or the "Phoenix Core" Telegram group. The platform is just a "surface," not the session's identity. -
No vendor lock-in. The architecture had to be modular. While I started with Telegram and Discord, I knew a Slack or Matrix adapter would eventually be necessary. The system needed a canonical internal message format, allowing any chat service to be plugged in or swapped out. The same applied to the LLM; it's Claude today, but it could be something else tomorrow.
-
Session state must survive process restarts. A conversational agent that gets amnesia every time I deploy a new version is useless. All session history, context, and cross-surface links needed to be persisted in a durable database, ensuring that conversations could be picked up days or weeks later, even after a full system reboot.
Architecture
The system is designed around a central message bus and a session resolver. Adapters for each chat platform are intentionally "dumb." They do one job: convert an incoming platform-specific message into a canonical Message format and push it onto the bus. The core logic then takes over.
Here’s a high-level flow of a message from a user to the agent and back:
User on Telegram User on Discord
| |
v v
[Telegram Adapter] [Discord Adapter]
| |
+-------+---------------+
|
v
[Message Bus (In-memory Queue)]
|
v
[Session Resolver]
(Finds/creates session in Postgres based on surface + thread ID)
|
v
[Context Builder]
(Fetches message history from Postgres)
|
v
[Claude Opus 4.7 API Call] ----> [MCP Tool Server]
(Sends history + new message) (web_fetch, github_search, etc.)
|
v
[Response Router]
(Receives Claude's reply, targets original surface)
|
v
+-------+---------------+
| |
[Telegram Adapter] [Discord Adapter]
| |
v v
User on Telegram User on Discord
The Message Bus is a simple in-memory queue managed by the main Bun process. The Session Resolver is the brains, querying a Postgres table to find the correct session ID. If a message is part of a bridged conversation, the resolver loads the shared context. The Response Router ensures the reply goes back to the platform where the last message originated.
This canonical Message type is the lifeblood of the system. It abstracts away the idiosyncrasies of each platform's API.
// The single source of truth for any message in the system.
interface CanonicalMessage {
// A unique, persistent ID for this message.
id: string;
// The session this message belongs to.
sessionId: string;
// 'telegram' | 'discord' | 'slack' etc.
surface: string;
// The channel/group/topic ID on the source platform.
threadId: string;
// The user who sent the message.
author: {
id: string;
username: string;
};
// The actual text content.
content: string;
// ISO 8601 timestamp.
timestamp: string;
// Optional reference to a message this one is replying to.
replyToId?: string;
}
Session Continuity (The Interesting Part)
Achieving seamless context across platforms was the main challenge. The solution has two key components: session linking and context management.
Sessions are keyed in the database by a composite of (surface, thread_root_id). A "thread root" could be a Telegram topic's creation message or the first message in a Discord thread. This ensures all replies within a platform-native thread map to the same session.
To bridge two threads, I added a /bridge command. An admin can go into a Discord channel and type /bridge <telegram_channel_id>. This command performs a database operation that effectively fuses the two session rows. It picks one session ID as the canonical one and updates all messages from the other session to point to it. From that moment on, the Session Resolver sees messages from either the Discord channel or the Telegram topic as belonging to the same conversation, and the Context Builder pulls the full, interleaved history.
Of course, this unified context can grow very large. To manage this, I implemented a context trimming strategy. When a session's token count exceeds 80% of Claude's window (e.g., ~160k tokens for a 200k window), a background job triggers. It takes the oldest 30% of the message history, sends it to Claude with a prompt to "succinctly summarize the key facts, decisions, and open questions from this part of the conversation," and then replaces that chunk of messages with a single assistant message containing the summary. This preserves key information while freeing up tokens for the ongoing discussion.
This system works surprisingly well. I measured its effectiveness by performing 200 manual conversation switches (e.g., ask a question on Discord, ask a follow-up on Telegram) and grading whether the agent's response correctly referenced information from the previous surface. The system maintained correct context in 188 out of 200 attempts, giving it a 94% continuity score. The failures were mostly due to ambiguous questions where context was helpful but not strictly required.
MCP Tool Wiring
An LLM without tools is just a chatbot. To give OpenClaw capabilities, I exposed a server that implements the Machine Communication Protocol (MCP). This provides a standardized, language-agnostic way for the LLM to call external functions. Claude's tool-use feature is configured to speak to this MCP endpoint.
The following tools are currently wired:
web_fetch: Fetches and summarizes the content of a URL.github_search: Searches for issues or code within a specified repository.linear_query: Runs a query against our Linear project management board.image_ocr: Extracts text from an image attachment.filesystem_read: Reads a file from a sandboxed, project-specific directory on the server.
To prevent abuse or runaway processes, the MCP server implements a token-bucket rate limit on a per-session basis. This ensures that a very active, tool-heavy conversation in one bridged thread can't starve other, less demanding sessions of their ability to use tools.
What It Actually Replaces
OpenClaw has become an indispensable part of my daily workflow.
- The Claude Desktop App: For quick questions, I no longer open a separate app. I just pop into a private Telegram chat with the bot. Since Telegram is always open on my phone and desktop, it's far more immediate.
- My Ops Console: I have a private Discord server bridged to a Telegram channel for monitoring my side projects. When I get a Grafana alert, I can ask OpenClaw to
filesystem_readthe relevant service log, summarize the error, andgithub_searchfor related issues, all from my phone. - Searching Linear while in a car: This is a game-changer. I can be a passenger and use a Telegram voice message to ask, "What's the status of the billing integration ticket?" OpenClaw (with a future Whisper integration) can query Linear via its tool and reply with the ticket's status and last update, all hands-free.
What Broke
The path to v0.5 was littered with bugs and bad assumptions.
- Telegram Rate Limits: An early feature was a
/broadcastcommand to send a message to all connected channels. The first time I used it across 10 channels, Telegram's API immediately hit me with a 429Too Many Requestserror. The fix was to implement a simple token-bucket queue on the outbound side of the adapters, ensuring we never exceed the platform's specified messages-per-second limit. - Discord's Reply Chains: In v0.2, every reply in a long Discord thread was creating a new, separate session. I had misread their API and was using the individual message ID as the session key. The fix in v0.3 was to correctly identify the
thread_root_idfor every message and use that as the stable identifier. - Bun's Buffer Issues: Users started attaching large log files and images. The initial implementation read the entire file into a buffer before processing, which caused Bun's process to crash with an out-of-memory error on files larger than a few hundred megabytes. I refactored the attachment handling logic to use streams, processing files chunk by chunk.
- Cost vs. Quality: The bot initially ran on Claude 3.5 Sonnet to save costs. It was fine for simple Q&A, but it struggled with complex, multi-surface context tracking and sophisticated tool use. After a two-week bake-off, I found that Claude 4.7 Opus, while more expensive, had a significantly lower rate of "context forgetting" and made more intelligent tool calls. The increase in quality was worth the cost, so I made it the default in v0.5.
Design Decisions I'd Defend
Looking back, I'm happy with a few key architectural choices that have paid dividends in simplicity and maintainability.
- A Pragmatic Monolith: The entire system runs as a single Bun process and uses just two Postgres tables:
sessionsandmessages. The temptation to build a fleet of microservices (one per adapter, one for sessions, etc.) was there, but it would have been overkill. The current design is simple to deploy, debug, and reason about. - Dumb Adapters, Smart Core: All platform-specific logic is isolated in the adapters. The core of the application operates only on the
CanonicalMessagetype and knows nothing about Discord's "guilds" or Telegram's "topics." This separation of concerns is critical. - Opt-In RAG: I deliberately avoided building a complex Retrieval-Augmented Generation (RAG) system by default. For most conversational use cases, Claude's large context window is sufficient. For specific projects that need to reference a knowledge base, I can enable a
pgvector-based RAG tool on a per-session basis, but it's not the default. - MCP for Tools: Instead of creating an ad-hoc registry of TypeScript functions for the agent to call, I chose to implement an MCP server. This forced a clean separation between the chat application and the tools it can use. It also means I could, in theory, write a tool in Python or Go and have OpenClaw use it without any changes to the core application.
What's Next (v0.6)
The work is far from done. The next major version will focus on expanding the bot's reach and capabilities.
- Slack Adapter: This is the most requested feature from collaborators who are tied to their corporate Slack workspaces.
- Voice Message Transcription: I plan to integrate a self-hosted Whisper model to transcribe incoming voice messages on Telegram, allowing for hands-free interaction with the agent.
- "Agent of Record": I want to introduce a concept where a specific agent can be designated as the "owner" of a thread. This would allow it to proactively participate, for example, by summarizing a long discussion that happened while I was asleep and providing a digest in the morning.
Source
OpenClaw is open-source under the MIT license. You can find the complete source code, including deployment instructions, on GitHub.
Repository: github.com/kieran/openclaw
It’s designed to be easily self-hosted. If you have Docker and Docker Compose installed, you can get a personal instance running with docker compose up after setting the required API keys in your environment file.