Multi-protocol message gateway for AI agents
A standalone Rust message gateway that bridges user-facing communication protocols (Telegram, Discord, Slack, Email) to backend agent protocols (Pipelit, OpenCode). Both user-facing adapters and backends can run as external subprocesses, making it easy to add new protocols in any language.
- Multi-protocol support — Telegram, Discord, Slack, Email, Generic HTTP/WebSocket
- External adapter architecture — Adapters run as separate processes, written in any language
- Pluggable backends — Built-in support for Pipelit and OpenCode; external backends run as subprocesses
- Named backend routing — Route different credentials to different backend instances
- Message filtering — CEL-based guardrails for inbound/outbound message validation
- File handling — Automatic download/upload of attachments with local caching
- Health monitoring — Emergency alerts when backend is unreachable
- Hot reload — Config and guardrail changes apply without restart
- Admin API — CRUD operations for credentials
The easiest way to get going is through the plit CLI, which bundles the gateway with an interactive setup wizard:
# Install plit (includes the gateway)
cargo install plit
# Bootstrap interactively (sets up Pipelit, LLM provider, credentials)
plit init
# Launch the full stack
plit startIf you only need the gateway binary or want to embed it as a library:
# Install the gateway binary on its own
cargo install plit-gw
# Or build from source
git clone https://github.com/theuselessai/plit-gw
cd plit-gw
cargo build --release
# Configure and run
cp config.example.json config.json
# Edit config.json with your credentials
GATEWAY_CONFIG=config.json ./target/release/plit-gwplit-gw is both a binary and a library crate. You can embed the gateway directly in a Rust application:
# Cargo.toml
[dependencies]
plit-gw = "0.3.1"#[tokio::main]
async fn main() -> anyhow::Result<()> {
plit_gw::run().await
}The run() function reads GATEWAY_CONFIG from the environment (defaulting to config.json) and blocks until the process receives a shutdown signal.
{
"gateway": {
"listen": "0.0.0.0:8080",
"admin_token": "${GATEWAY_ADMIN_TOKEN}",
"default_backend": "pipelit",
"adapters_dir": "./adapters",
"adapter_port_range": [9000, 9100],
"guardrails_dir": "./guardrails",
"backends_dir": "./backends",
"backend_port_range": [9200, 9300]
},
"backends": {
"pipelit": {
"protocol": "pipelit",
"inbound_url": "http://localhost:8000/api/v1/inbound",
"token": "${PIPELIT_API_TOKEN}",
"active": true
},
"opencode": {
"protocol": "external",
"adapter_dir": "./backends/opencode",
"token": "${OPENCODE_BACKEND_TOKEN}",
"active": true,
"config": {
"base_url": "http://127.0.0.1:4096",
"token": "${OPENCODE_API_TOKEN}",
"model": {
"providerID": "anthropic",
"modelID": "claude-sonnet-4-5"
}
}
}
},
"auth": {
"send_token": "${GATEWAY_SEND_TOKEN}"
},
"credentials": {
"my_telegram": {
"adapter": "telegram",
"backend": "pipelit",
"token": "${TELEGRAM_BOT_TOKEN}",
"active": true,
"route": {"channel": "telegram"}
}
}
}Environment variables can be referenced with ${VAR_NAME} syntax.
Backends receive messages from the gateway and process them with AI/LLM services. The gateway supports two backend types:
Built-in backends are compiled into the gateway binary:
- Pipelit (
protocol: "pipelit") — Webhook-based backend with callback support - OpenCode (
protocol: "opencode") — REST + SSE backend with session management (built-in Rust implementation)
External backends run as separate subprocesses in backends_dir. Each backend directory contains:
backends/opencode/
├── adapter.json # {"name": "opencode", "command": "node", "args": ["dist/main.js"]}
├── dist/main.js # Backend implementation
└── package.json
External backends receive environment variables:
BACKEND_PORT— Port to listen onGATEWAY_URL— Gateway callback URLBACKEND_TOKEN— Auth token for gateway requestsBACKEND_CONFIG— JSON config blob (fromconfig.backends[name].config)
External backends must implement:
POST /send— Receive messages from gatewayGET /health— Health check endpoint
Each credential specifies which backend to route to via the backend field. The gateway spawns one backend instance per named backend entry in config.backends, shared across all credentials referencing that backend.
Adapters are external processes in adapters_dir. Each adapter directory contains:
adapters/telegram/
├── adapter.json # {"name": "telegram", "command": "python3", "args": ["main.py"]}
├── main.py # Adapter implementation
└── requirements.txt
Adapters receive environment variables:
INSTANCE_ID— Unique instance identifierADAPTER_PORT— Port to listen onGATEWAY_URL— Gateway callback URLCREDENTIAL_ID— Credential identifierCREDENTIAL_TOKEN— Protocol auth tokenCREDENTIAL_CONFIG— JSON config blob
The generic adapter is built-in and provides a REST + WebSocket interface without any external process:
# Send message via REST
curl -X POST http://localhost:8080/api/v1/chat/my_generic \
-H "Authorization: Bearer $TOKEN" \
-d '{"text": "Hello"}'
# Connect via WebSocket
wscat -c ws://localhost:8080/ws/chat/my_generic/session1 \
-H "Authorization: Bearer $TOKEN"Guardrails let you filter inbound messages using CEL (Common Expression Language) expressions. Each rule is a JSON file in guardrails_dir. Rules are evaluated in lexicographic filename order, so zero-padded prefixes (01-, 02-, ...) give you predictable ordering.
Each file contains a single JSON object:
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Human-readable rule name |
type |
"cel" |
"cel" |
Rule type (only CEL supported) |
expression |
string | required | CEL expression that must evaluate to bool |
action |
"block" or "log" |
"block" |
What to do when the expression is true |
direction |
"inbound", "outbound", or "both" |
"inbound" |
Which messages to apply the rule to |
on_error |
"allow" or "block" |
"allow" |
Behavior when CEL evaluation fails |
reject_message |
string | none | Body returned in the HTTP 403 response when blocked |
enabled |
bool | true |
Set to false to disable without deleting the file |
The message variable is available in every expression:
# Block messages containing sensitive keywords (Rust regex syntax)
message.text.matches("(?i)(password|secret|api_key)")
# Block messages over 10000 characters
size(message.text) > 10000
# Log messages that include file attachments (never blocks)
size(message.attachments) > 0
# Block messages from a specific source protocol
message.source.protocol == "telegram"
{
"name": "block-sensitive-keywords",
"expression": "message.text.matches(\"(?i)(password|secret|api_key)\")",
"action": "block",
"reject_message": "Message contains sensitive keywords and cannot be forwarded."
}{
"name": "audit-attachments",
"expression": "size(message.attachments) > 0",
"action": "log"
}matches()uses Rust regex syntax, not RE2 or the Google CEL spec. Lookaheads and backreferences are not supported. Case-insensitive matching uses the(?i)flag.has()is not available.Option<T>fields serialize asnullwhenNone, so CEL sees them asnullrather than absent. However, fields withskip_serializing_if(likeattachmentswhen empty) are omitted from the CEL context entirely. Useon_error: "allow"(the default) so rules referencing omitted fields fail open instead of blocking.- Outbound guardrails are not evaluated in v1. Only
"direction": "inbound"rules take effect.
Guardrail rules reload automatically when rule files in guardrails_dir change. No restart needed. The same applies to the main config.json — the gateway watches for changes and applies them without dropping connections.
Point guardrails_dir at a directory of rule files:
{
"gateway": {
"guardrails_dir": "./guardrails"
}
}If guardrails_dir is omitted and a guardrails/ directory exists next to config.json, it's picked up automatically.
| Endpoint | Description |
|---|---|
GET /health |
Health check |
POST /api/v1/send |
Send message to user (backend to gateway to adapter) |
POST /api/v1/adapter/inbound |
Receive message from adapter |
GET /files/{id} |
Download cached file |
GET /admin/credentials |
List credentials |
POST /admin/credentials |
Create credential |
PATCH /admin/credentials/{id}/activate |
Activate credential |
- plit — CLI tool for chat, admin, and agent integration. The easiest way to run and interact with the gateway.
- Pipelit — The workflow backend that
plit-gwwas originally built to front.
# Run tests
cargo test
# Run with coverage
cargo llvm-cov
# Lint
cargo clippy -- -D warnings
# Format
cargo fmtInstall a pre-push hook to catch issues before CI:
cat > .git/hooks/pre-push << 'EOF'
#!/bin/bash
set -e
echo "Running pre-push checks..."
cargo fmt --all -- --check || { echo "Run: cargo fmt --all"; exit 1; }
cargo clippy --all-targets --all-features -- -D warnings || { echo "Fix clippy warnings"; exit 1; }
cargo test --all-features || { echo "Tests failed"; exit 1; }
echo "All checks passed"
EOF
chmod +x .git/hooks/pre-pushThis prevents formatting, linting, and test failures from reaching CI.
Contributions are welcome! Please read CLAUDE.md for detailed development guidelines.
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Install the pre-push hook (see Development section above)
- Make your changes
- Run the full check suite:
cargo fmt --all && cargo clippy -- -D warnings && cargo test - Commit your changes (
git commit -m 'feat: add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
Before opening a PR, verify:
-
cargo fmt --all— Code is formatted -
cargo clippy --all-targets --all-features -- -D warnings— No warnings -
cargo test --all-features— All tests pass -
cargo build --release— Release build succeeds - Error handling uses
Result<?>/map_err(nounwrap()/expect()in production code) - Structured logging includes relevant context fields (e.g.,
credential_id,message_id) - Config secrets use
${ENV_VAR}syntax (no hardcoded tokens) - New functionality includes tests
All PRs must pass CI checks (lint, test, build) and AI code review.
Licensed under the Apache License, Version 2.0. See LICENSE for details.