The Inference Gateway CLI supports pluggable messaging channels that let you remote-control the agent from external platforms like Telegram or WhatsApp.
- Overview
- Architecture
- Quick Start (Telegram)
- Configuration
- Security
- Adding a Custom Channel
- Supported Channels
- Troubleshooting
Channels provide a bridge between external messaging platforms and the CLI
agent. The infer channels-manager command runs as a standalone long-running daemon
that listens for messages from platforms like Telegram. When a message arrives,
it triggers infer agent --session-id <id> as a subprocess. The agent
processes the message and the response is sent back through the channel.
Key features:
- Pluggable: Add new platforms by implementing a single Go interface
- Decoupled: Channel listener is independent from the agent
- Secure by default: Allowlist-based access control per channel
- Persistent sessions: Deterministic session IDs per sender
- Text and image support: Forward text messages and images to the agent
┌──────────┐ ┌────────────────────────────────────────────────────┐
│ Telegram │◀─────▶│ infer channels-manager (long-running daemon) │
│ Bot API │ │ │
└──────────┘ │ Channel ──▶ inbox ──▶ routeInbound │
│ │ │
│ ┌───────────────────────▼─────────────┐ │
│ │ Per message: │ │
│ │ 1. Check allowlist │ │
│ │ 2. Derive session ID │ │
│ │ 3. exec: infer agent │ │
│ │ --session-id channel-telegram-X │ │
│ │ "user message" │ │
│ │ 4. Parse JSON stdout │ │
│ │ 5. Send response via channel │ │
│ └─────────────────────────────────────┘ │
└────────────────────────────────────────────────────┘
Message flow:
- External platform delivers a message to the Channel implementation
- Channel converts it to an
InboundMessageand sends to the shared inbox - Channel Manager checks the sender against the allowlist
- If authorized, derives a deterministic session ID (e.g.,
channel-telegram-123456789) - Triggers
infer agent --session-id <id> "<message>"as a subprocess - Parses the agent's JSON stdout for the assistant response
- Sends the response back through the originating channel
- Open Telegram and message @BotFather
- Send
/newbotand follow the prompts - Copy the bot token (e.g.,
123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11)
- Message your new bot in Telegram
- Visit
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates - Find your
chat.idin the response (a numeric ID like123456789)
Channel settings live in their own file at .infer/channels.yaml (separate
from the main config.yaml so bot tokens stay out of the agent's reach —
the file is in tools.sandbox.protected_paths by default). infer init
seeds it from the in-code defaults; edit it like so:
# .infer/channels.yaml
---
enabled: true
telegram:
enabled: true
bot_token: "${INFER_CHANNELS_TELEGRAM_BOT_TOKEN}"
allowed_users:
- "123456789" # your chat ID
poll_timeout: 30Agent-side settings still live in .infer/config.yaml:
agent:
model: "openai/gpt-4"
system_prompt: "You are a helpful assistant"
custom_instructions: "" # clear default instructions for lightweight channel use
max_turns: 1 # recommended for conversational channel useOr use environment variables:
export INFER_CHANNELS_ENABLED=true
export INFER_CHANNELS_TELEGRAM_ENABLED=true
export INFER_CHANNELS_TELEGRAM_BOT_TOKEN="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
export INFER_CHANNELS_TELEGRAM_ALLOWED_USERS="123456789"
export INFER_AGENT_MODEL="openai/gpt-4"infer channels-managerThis starts a long-running daemon that listens for Telegram messages. Each
incoming message triggers a new infer agent invocation with a persistent
session per sender.
Open Telegram, message your bot, and the agent will respond.
Channel settings live in their own file:
.infer/channels.yaml— all channel settings (Telegram, WhatsApp, max workers, approval flag). Holds bot tokens, so it's listed intools.sandbox.protected_pathsand the agent cannot read or rewrite it..infer/config.yaml— agent settings (model, system prompt, etc.). Any legacychannels:block here is ignored at runtime; onlychannels.yamlis read. Runinfer initto migrate an existing block: it seedschannels.yamlfrom the loaded values when nochannels.yamlexists yet.
The lookup order for channels.yaml is project (./.infer/channels.yaml)
first, then userspace (~/.infer/channels.yaml).
# .infer/channels.yaml
---
# Master switch for all channels
enabled: false
# Worker pool size for processing inbound messages
max_workers: 5
# Image retention (number of recent images cached per session)
image_retention: 5
# Require user approval for sensitive tools (default: true)
# When true, tools like Write, Edit, Delete, and Bash will prompt the user
# for approval before executing. Read-only tools (Read, Grep, Tree) are
# not affected. Reuses existing tools.*.require_approval configuration.
require_approval: true
# Telegram Bot API channel
telegram:
enabled: false
bot_token: "" # Bot token from @BotFather
allowed_users: [] # List of allowed chat IDs (strings)
poll_timeout: 30 # Long-polling timeout in seconds
# WhatsApp Business API channel (Phase 2 - not yet implemented)
whatsapp:
enabled: false
phone_number_id: "" # Meta Business phone number ID
access_token: "" # Meta API access token
verify_token: "" # Webhook verification token
webhook_port: 8443 # Local port for webhook receiver
allowed_users: [] # List of allowed phone numbers# .infer/config.yaml — recommended agent settings for channel use
agent:
model: "deepseek/deepseek-v4-pro" # Model to use
system_prompt: "You are a helpful assistant" # Base identity
custom_instructions: "" # Clear default instructions for lightweight use
max_turns: 1 # Single-turn for conversational useAll channel settings can be configured via environment variables with the
INFER_ prefix:
| Setting | Environment Variable |
|---|---|
channels.enabled |
INFER_CHANNELS_ENABLED |
channels.require_approval |
INFER_CHANNELS_REQUIRE_APPROVAL |
channels.telegram.enabled |
INFER_CHANNELS_TELEGRAM_ENABLED |
channels.telegram.bot_token |
INFER_CHANNELS_TELEGRAM_BOT_TOKEN |
channels.telegram.allowed_users |
INFER_CHANNELS_TELEGRAM_ALLOWED_USERS |
channels.telegram.poll_timeout |
INFER_CHANNELS_TELEGRAM_POLL_TIMEOUT |
Channels enforce a secure-by-default policy:
- If
allowed_usersis empty, all messages are rejected - Only senders whose ID appears in the allowlist can interact with the agent
- Each channel has its own independent allowlist
- Unauthorized attempts are logged for monitoring
- Always set
allowed_users- never run with an empty list in production - Use environment variables for tokens - avoid committing secrets to config files
- Use a dedicated bot - don't reuse bots across projects
- Monitor logs - watch for unauthorized access attempts
By default (channels.require_approval: true), the channel manager enables
interactive tool approval for sensitive operations. When the agent needs to
execute a tool that requires approval (e.g., Write, Edit, Delete, Bash), it
prompts the channel user and waits for confirmation.
- The channel manager spawns
infer agent --require-approval - When the agent encounters a tool that requires approval, it outputs a JSON approval request on stdout and blocks
- The channel manager detects the request and sends a human-readable prompt
to the user (e.g., "Tool approval required: Bash / Command:
rm -rf tmp/") - The user replies yes (or y, approve, ok) to approve, or no (or n, reject) to reject
- The channel manager writes the approval response to the agent's stdin
- If no reply is received within 5 minutes, the tool is automatically rejected
This reuses the existing tools.*.require_approval configuration:
| Tool | Default |
|---|---|
| Bash | Requires approval (unless command is whitelisted) |
| Write | Requires approval |
| Edit | Requires approval |
| Delete | Requires approval |
| Read | No approval needed |
| Grep | No approval needed |
| Tree | No approval needed |
| TodoWrite | No approval needed |
You can customize per-tool behavior in .infer/config.yaml:
tools:
bash:
require_approval: true
whitelist:
commands: ["ls", "pwd", "git status"]
write:
require_approval: true
read:
require_approval: falseTo disable approval and auto-execute all tools (original behavior):
channels:
require_approval: falseOr: INFER_CHANNELS_REQUIRE_APPROVAL=false
Each sender gets a deterministic session ID (e.g.,
channel-telegram-123456789). The agent uses --session-id to persist
conversations, which means:
- Full conversation history: The agent loads all prior messages when calling the LLM. Your Telegram bot remembers everything discussed.
- Auto-compaction: When the conversation grows too long, auto-compact
summarizes older messages to stay within context limits. This is controlled
by the existing
compact.auto_atsetting (default: 80% of context window). - Persistent storage: If you use a persistent storage backend (JSONL, SQLite, PostgreSQL), conversations survive restarts.
- No extra configuration needed: Conversation memory works out of the box using the same settings as regular agent sessions.
To add support for a new messaging platform, implement the domain.Channel
interface:
// Channel represents a pluggable messaging transport
type Channel interface {
Name() string
Start(ctx context.Context, inbox chan<- InboundMessage) error
Send(ctx context.Context, msg OutboundMessage) error
Stop() error
}- Create the implementation in
internal/services/channels/your_channel.go:
package channels
type MyChannel struct {
cfg config.MyChannelConfig
}
func (c *MyChannel) Name() string { return "mychannel" }
func (c *MyChannel) Start(
ctx context.Context,
inbox chan<- domain.InboundMessage,
) error {
// Poll or listen for messages, send to inbox channel
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
msg := waitForMessage()
inbox <- domain.InboundMessage{
ChannelName: "mychannel",
SenderID: msg.From,
Content: msg.Text,
Timestamp: time.Now(),
}
}
}
}
func (c *MyChannel) Send(
ctx context.Context,
msg domain.OutboundMessage,
) error {
// Deliver the response through your platform's API
return sendToMyPlatform(msg.RecipientID, msg.Content)
}
func (c *MyChannel) Stop() error { return nil }-
Add config types to
config/config.go -
Register in the channels command in
cmd/channels.go:
if cfg.Channels.MyChannel.Enabled {
ch := channels.NewMyChannel(cfg.Channels.MyChannel)
cm.Register(ch)
}- Add allowlist check in
channel_manager.go:
case "mychannel":
allowedUsers = cm.cfg.MyChannel.AllowedUsers- Write tests in
internal/services/channels/your_channel_test.go
| Channel | Status | Transport | Notes |
|---|---|---|---|
| Telegram | Available | Long-polling (Bot API) | No webhook needed, works behind NAT |
| Planned | Webhook (Meta Business) | Requires Meta Business account | |
| Discord | Not yet | - | Contributions welcome |
| Slack | Not yet | - | Contributions welcome |
- Check
channels.enabled: trueandchannels.telegram.enabled: true - Verify the bot token is correct:
curl https://api.telegram.org/bot<TOKEN>/getMe - Ensure your chat ID is in
allowed_users - Check CLI logs for
[channels]or[telegram]entries
- Verify the channel listener is running (
infer channels-manager) - Check that
infer agentworks independently:infer agent "Hello" --session-id test - Look for
[channels] agent failedin logs
Telegram has rate limits (approximately 30 messages per second). For long responses, the channel automatically splits messages into 4096-character chunks.
If you see [channels] rejected message from unauthorized user, add the
sender's chat ID to the allowed_users list. You can find chat IDs in
the log messages.