Connect any AI agent runtime to any messaging platform.
Existing chatbot frameworks (AstrBot, LangBot, Botpress) run their own agent loop — they call LLM APIs, parse tool calls, execute tools locally, and manage context. When you connect them to a managed agent runtime like Claude Managed Agents, they have to bypass their entire infrastructure just to pipe messages through.
HarnessGate takes a different approach: no local agent loop. It's a pure bridge that delegates all intelligence to the provider runtime. The gateway just routes messages between platforms and the agent.
This means:
- Claude Managed Agents features work out of the box (tool confirmation, custom tools, multi-agent threads, extended thinking)
- Any future agent runtime plugs in with 4 methods
- No competing agent loops, no bypassed infrastructure, no wasted abstractions
[Telegram] [Discord] [Slack] [WhatsApp] [Teams] [Web UI]
| | | | | |
+----+----+----+---+-------+----------+------+
| |
PlatformAdapter interface (per platform)
| |
+----+----+
|
Bridge (orchestrator)
SessionMap + StreamManager
|
Provider interface
|
+----------+----------+----------+
| Claude | HTTP | Custom |
| Managed | (any | (npm pkg |
| Agents | server) | or file)|
+----------+----------+----------+
- Provider-agnostic — Claude Managed Agents, any HTTP server, or bring your own
- Platform adapters — Telegram, Discord, Slack, WhatsApp, Teams, Web UI
- Multi-app — run multiple app instances per platform, each mapped to a different agent
- Session management — automatic session creation, SQLite persistence, multi-turn conversations
- Buffer-then-send — accumulates agent responses, sends as one message per turn
- Auto-split — respects per-platform message length limits
- Event passthrough — provider-specific events forwarded via
bridge.onEvent()listeners
npm install harnessgateimport { Bridge, ClaudeProvider, TelegramAdapter } from "harnessgate";
const provider = new ClaudeProvider(process.env.ANTHROPIC_API_KEY!);
const bridge = new Bridge(provider, {
provider: { type: "claude" },
platforms: { telegram: { botToken: process.env.TELEGRAM_BOT_TOKEN! } },
});
// Route users to agents (agentId + environmentId from your DB)
bridge.setUserResolver(async (sender) => ({
userId: sender.id,
agentId: "agent_01XXXX",
environmentId: "env_01XXXX",
}));
bridge.addPlatform(new TelegramAdapter());
await bridge.start();See examples/demo-web/ for a minimal starter, examples/demo-telegram/ for a Telegram bot, or examples/with-supabase/ for a production starter with Supabase auth and session persistence.
git clone https://github.com/your-org/harnessgate.git
cd harnessgate
pnpm install
cd examples/demo-telegram
cp .env.example .env
# Add ANTHROPIC_API_KEY in .env
# Fill in botToken in src/main.ts
# Implement your agentId/environmentId lookup in the user resolver
pnpm build
node --env-file=.env dist/main.jsHarnessGate supports three ways to connect an agent runtime:
provider:
type: claude
apiKey: ${ANTHROPIC_API_KEY}Connects to Claude Managed Agents. Full support for streaming, tool confirmation, custom tools, extended thinking, and multi-agent threads.
For Claude, agentId and environmentId come from your UserResolver, not static provider config:
bridge.setUserResolver(async (sender) => ({
userId: sender.id,
agentId: "agent_01XXXX",
environmentId: "env_01XXXX",
}));provider:
type: http
baseUrl: http://localhost:8080
headers:
Authorization: Bearer ${MY_TOKEN}Connects to any HTTP server that implements these endpoints:
| Endpoint | Request | Response |
|---|---|---|
POST /sessions |
{ systemPrompt? } |
{ id: "session-123" } |
POST /sessions/{id}/message |
{ message: "hello", sessionId: "..." } |
200 OK |
GET /sessions/{id}/stream |
SSE stream | data: {"type": "message", "text": "..."} |
DELETE /sessions/{id} |
— | 200 OK |
Your server can be written in any language. SSE events can use either format:
# HarnessGate-native format (recommended)
data: {"type": "message", "text": "Hello!"}
data: {"type": "status", "status": "idle"}
# Simple format (auto-detected)
data: {"response": "Hello!"}
data: {"text": "Hello!"}
Custom endpoint paths:
provider:
type: http
baseUrl: http://localhost:8080
endpoints:
createSession: POST /api/conversations
sendMessage: POST /api/conversations/{sessionId}/chat
stream: GET /api/conversations/{sessionId}/events
destroySession: DELETE /api/conversations/{sessionId}provider:
type: "@my-org/my-langgraph-provider" # npm package
apiKey: xxx
# or
provider:
type: "./my-provider.js" # local file
apiKey: xxxThe package/file must default-export a class implementing the Provider interface:
import type { Provider } from "harnessgate";
export default class MyProvider implements Provider {
readonly id = "my-provider";
readonly capabilities = {
interrupt: false,
toolConfirmation: false,
customTools: false,
thinking: false,
};
constructor(config: Record<string, unknown>) {
// config contains everything from the provider block in YAML
}
async createSession(opts) { /* ... */ }
async sendMessage(sessionId, message) { /* ... */ }
async *stream(sessionId, signal) { /* ... */ }
async destroySession(sessionId) { /* ... */ }
}HarnessGate supports per-user access control and agent routing via a UserResolver:
bridge.setUserResolver(async (sender, platform, message) => {
const user = await db.findUser(platform, sender.id);
if (!user?.isActive) return null; // reject
return {
userId: user.id,
agentId: user.agentId, // which agent template
environmentId: user.envId, // which environment
metadata: { plan: user.plan }, // passed to provider session
};
});Return null to reject. When no resolver is set, all users are allowed with their platform ID as the user ID.
Claude requires the resolver to return both agentId and environmentId for session creation.
Each conversation context gets its own Claude session:
| Context | Session scope | Example key |
|---|---|---|
| DM | Per user | telegram:direct:123:u:user99 |
| Group/Channel | Shared (all users) | slack:group:ch1 |
| Thread | Per thread | discord:thread:ch1:t:thread99 |
Sessions are persisted to SQLite by default (survives restarts). Configurable:
session:
store: sqlite # "sqlite" (default) or "memory"
path: ./harnessgate.db # SQLite file path (only for sqlite)For custom stores (Supabase, Postgres, Redis), use the library mode:
bridge.setSessionStore({
async get(key) { /* query your DB */ },
async set(key, entry) { /* upsert */ },
async delete(key) { /* delete */ },
async touch(key) { /* update lastActiveAt */ },
});See examples/with-supabase/main.ts for a complete example with Supabase for both auth and session persistence.
Every platform adapter supports running multiple app instances simultaneously. Each app connects to the platform and receives a platform-assigned appId — an opaque identifier that flows through every InboundMessage and ChannelTarget.
// Add multiple Telegram bots at runtime
const supportBotId = await bridge.connect("telegram", { botToken: process.env.SUPPORT_BOT_TOKEN });
const salesBotId = await bridge.connect("telegram", { botToken: process.env.SALES_BOT_TOKEN });
// Route based on which bot received the message
bridge.setUserResolver(async (sender, platform, message) => {
const agentId = await db.getAgentForBot(message.appId);
const environmentId = await db.getEnvironmentForBot(message.appId);
return { userId: sender.id, agentId, environmentId };
});Each platform exposes a different identifier as appId. The adapter reads it from the platform SDK after connecting:
| Platform | Source | Example value |
|---|---|---|
| Telegram | bot.botInfo.id |
"123456789" |
| Discord | client.application.id |
"1098765432101234567" |
| Slack | event.api_app_id |
"A0123456789" |
| WABA phone number ID | "106540352267890" |
|
| Teams | activity.recipient.id |
"28:abc123..." |
| Web | N/A (single instance) | — |
The appId is included in session keys as app:<appId>, so each bot maintains separate conversation sessions even in the same channel.
platforms:
web:
enabled: true
port: 3000
telegram:
enabled: false
botToken: ${TELEGRAM_BOT_TOKEN}
discord:
enabled: false
token: ${DISCORD_BOT_TOKEN}
slack:
enabled: false
botToken: ${SLACK_BOT_TOKEN}
appToken: ${SLACK_APP_TOKEN}
whatsapp:
enabled: false
phoneNumberId: ${WHATSAPP_PHONE_NUMBER_ID}
accessToken: ${WHATSAPP_ACCESS_TOKEN}
verifyToken: ${WHATSAPP_VERIFY_TOKEN}
webhookPort: 8080
teams:
enabled: false
appId: ${TEAMS_APP_ID}
appPassword: ${TEAMS_APP_PASSWORD}
webhookPort: 3978
logging:
level: infoEnvironment variables are interpolated via ${VAR} syntax.
Detailed setup instructions for each platform, including SaaS multi-tenant distribution:
| Platform | Guide | Library |
|---|---|---|
| Telegram | docs/setup-telegram.md |
grammY |
| Discord | docs/setup-discord.md |
discord.js |
| Slack | docs/setup-slack.md |
@slack/bolt |
docs/setup-whatsapp.md |
Cloud API (fetch) | |
| Teams | docs/setup-teams.md |
botbuilder |
| Web | docs/setup-web.md |
Built-in HTTP |
harnessgate/
├── src/
│ ├── index.ts # Barrel exports
│ ├── bridge.ts # Orchestrator
│ ├── session-map.ts # Session persistence
│ ├── stream-manager.ts # SSE stream lifecycle
│ ├── platforms/
│ │ ├── telegram-adapter.ts # grammY
│ │ ├── discord-adapter.ts # discord.js
│ │ ├── slack-adapter.ts # @slack/bolt
│ │ ├── whatsapp-adapter.ts # Cloud API (fetch)
│ │ ├── teams-adapter.ts # Bot Framework SDK
│ │ └── web-adapter.ts # Built-in HTTP + SSE
│ └── providers/
│ ├── claude-provider.ts # Claude Managed Agents
│ └── http-provider.ts # Generic HTTP
├── docs/ # Platform setup guides
└── examples/ # Starter projects
See CONTRIBUTING.md for step-by-step guides on adding platforms and providers.
interface Provider {
readonly id: string;
readonly capabilities: ProviderCapabilities;
// Required — every provider
createSession(opts: CreateSessionOpts): Promise<ProviderSession>;
sendMessage(sessionId: string, message: MessagePayload): Promise<void>;
stream(sessionId: string, signal: AbortSignal): AsyncIterable<ProviderEvent>;
destroySession(sessionId: string): Promise<void>;
// Optional — capability-gated
interrupt?(sessionId: string): Promise<void>;
confirmTool?(sessionId: string, toolUseId: string, approved: boolean): Promise<void>;
submitToolResult?(sessionId: string, toolUseId: string, result: unknown): Promise<void>;
}interface PlatformAdapter {
readonly id: string;
readonly capabilities: PlatformCapabilities;
start(ctx: PlatformContext): Promise<void>;
stop(): Promise<void>;
send(target: ChannelTarget, message: OutboundMessage): Promise<SendResult>;
sendTyping?(target: ChannelTarget): Promise<void>;
connect?(credentials: Record<string, unknown>, ctx: PlatformContext): Promise<string>;
disconnect?(appId: string): Promise<void>;
activeConnections?(): string[];
}- Node.js >= 22
- pnpm
MIT