Channels
Channels turn the Inference Gateway CLI agent into a remote-controllable bot accessible from messaging platforms like Telegram. Send a message to your bot and the agent runs as if you launched it locally - with persistent per-sender conversation memory, allowlist-based access control, and optional approval prompts for sensitive tools.
Note: Channels are one subsystem of
infer daemon, the hub that also serves the desktop app and the browser extension - the same event stream and the same approval contract reach every client. Telegram is fully supported today; WhatsApp is planned and Discord/Slack are open for contributions.
Key Features
- Pluggable adapters - One daemon, multiple platforms. Add new channels by implementing a small Go interface
- One thread per sender - Each chat ID gets a deterministic session (
channel-<name>-<sender_id>) served by a long-lived session worker inside the daemon, so conversations persist across messages and daemon restarts. No process is spawned per message - Allowlist-only access - Empty allowlist rejects all messages. Secure by default
- Tool approval prompts - Sensitive tools (Bash, Write, Edit, Delete) ask for confirmation through the channel before running
- Image attachments - Send a photo to the bot and the agent receives it as part of the prompt
- Voice transcription - Inbound voice messages are transcribed to text with Whisper when speech-to-text is enabled
- Scheduled tasks - Combine with the Schedule tool to ask the bot for recurring or one-off jobs delivered back through the same chat
How Channels Work
A channel is a driving adapter of the daemon's thread registry: the chat is a client of a session worker, exactly like the desktop app or the browser extension. When the daemon receives a message, it routes it through a small pipeline:
Messaging platform (e.g. Telegram)
│
▼
Channel adapter (long-polls or webhook)
│
▼
Channel manager - checks the allowlist
│
▼
Thread registry: resume_conversation (thread channel-<name>-<sender_id>)
user_message (text + images as attachments)
│
▼
Session worker emits the shared AG-UI event stream
│
├── approval_request → prompt sent back through the channel,
│ answered with an approval_response frame
│
├── user_question_request → dismissed (a chat has no form to fill)
│
└── assistant message → rendered and sent back as a replyThe daemon holds one thread per chat. The first message on a chat sends a resume_conversation frame for the sender's deterministic session id in the daemon's working directory, and every message - the first one included - sends a user_message frame carrying the text, with any images riding along as attachments. The worker runs with the same remote-control system prompt that infer headless --remote selects.
Because each sender gets a dedicated session ID, conversation history, model state, and any in-flight context are preserved across messages - even after restarting the daemon. A chat that sits idle is detached from its thread and the registry reaps the worker; the next message reattaches to the same session.
The daemon must be running for a channel to work: channels have no agent of their own, they drive the daemon's threads.
Telegram
Quick Setup
1. Create a Telegram bot. Message @BotFather on Telegram and send /newbot. Follow the prompts and copy the bot token (it looks like 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11).
2. Get your chat ID. Send any message to your new bot, then visit:
https://api.telegram.org/bot<YOUR_TOKEN>/getUpdatesFind the "chat":{"id":123456789} field in the JSON response - that number is your chat ID.
3. Configure in .infer/config.yaml:
channels:
enabled: true
telegram:
enabled: true
bot_token: '${INFER_CHANNELS_TELEGRAM_BOT_TOKEN}'
allowed_users:
- '123456789'
poll_timeout: 30Or via environment variables:
export INFER_CHANNELS_ENABLED=true
export INFER_CHANNELS_TELEGRAM_ENABLED=true
export INFER_CHANNELS_TELEGRAM_BOT_TOKEN="123456:ABC-DEF..."
export INFER_CHANNELS_TELEGRAM_ALLOWED_USERS="123456789"4. Start the daemon:
infer daemon5. Message your bot in Telegram - the agent will respond.
Try it end-to-end: The CLI repo ships a Docker Compose example - gateway, daemon, and an A2A browser agent so you can ask the bot to surf the web from your phone. See
examples/telegram-channel.
Configuration Options
| Key | Type | Default | Description |
|---|---|---|---|
channels.telegram.enabled | bool | false | Turn the Telegram adapter on |
channels.telegram.bot_token | string | "" | Bot token from @BotFather. Reference an env var to keep secrets out of git |
channels.telegram.allowed_users | []string | [] | Telegram chat IDs allowed to message the bot. Empty list rejects everyone |
channels.telegram.poll_timeout | int | 30 | Long-polling timeout in seconds |
Access Control
The allowlist is mandatory. If allowed_users is empty, every incoming message is rejected and logged as unauthorized - this is the secure default.
Best practices:
- Always set
allowed_usersbefore exposing the bot to anyone - Use environment variables for
bot_token; never commit it to git - Each channel has its own independent allowlist - adding a Telegram chat ID does not authorize the same person on WhatsApp
- Watch the daemon logs for repeated unauthorized attempts
Tool Approval
By default, sensitive tools (Bash, Write, Edit, Delete) request explicit user approval before running. The worker raises an approval_request interrupt, the daemon turns it into an inline keyboard message in Telegram, and the user taps Approve or Reject. The answer goes back as an approval_response frame - the same contract every other daemon client answers on, so a decision made in the terminal or the desktop app resolves the chat's prompt too.
Read-only tools (Read, Grep, Tree) execute without prompting.
Approval is wired through the existing tools.*.require_approval configuration. To stop the chat from prompting:
channels:
require_approval: false # default: trueOr set INFER_CHANNELS_REQUIRE_APPROVAL=false.
This does not grant the tools. With
require_approval: falsethe chat answers rejected without asking, so gated tools stay blocked - a chat is an unattended surface, and silently auto-approvingBashon it would be a remote shell. Ungate a tool intools.*.require_approvalif you want the agent to run it from a chat.
Approvals time out after 5 minutes and are auto-rejected if the user does not respond.
AskUserQuestion is dismissed on a channel: a chat has no form to fill, so the tool takes its dismissed path and the agent continues without the answer.
Scheduled Tasks
When the daemon is running, you can ask the bot to schedule prompts on a cron schedule. The agent's response is delivered back through the originating channel - for example, the same Telegram chat where you set it up:
"Send me an inspiring quote every day at 8 AM" - recurring "Remind me at 6pm today to call mum" - one-off (deletes itself after firing)
Enable it in ~/.infer/tools.yaml (the schedule section sits under tools: - the tools policy is userspace-only, see Tool Configuration):
# ~/.infer/tools.yaml
tools:
schedule:
enabled: true # disabled by default
require_approval: true # recommendedOr via environment variable: INFER_TOOLS_SCHEDULE_ENABLED=true.
Jobs are persisted as YAML in ~/.infer/schedules/<id>.yaml and hot-reloaded by the daemon - no restart needed when the agent creates a new schedule. The channel and recipient are derived automatically from the originating session, so the LLM never has to guess them.
Container deployments must set
TZ=Europe/Berlin(or your zone) so cron expressions are interpreted in local time. The binary embeds the IANA zone database, so this works on any base image.
Long Messages and Images
- Message splitting - Telegram caps messages at 4096 characters. The adapter splits longer responses automatically into sequential messages
- Image input - Photos sent to the bot (with or without a caption) are downloaded via the Telegram API and forwarded to the agent as base64-encoded attachments
- Image retention -
channels.image_retentioncontrols how many recent images are kept per session (default:5)
Voice Messages
When speech-to-text is enabled, voice notes sent to the bot are downloaded via the Telegram API, decoded with ffmpeg (OGG/Opus to WAV), transcribed locally with Whisper, and forwarded to the agent as text - so you can talk to the agent from your phone.
Voice transcription is gated behind the speech_to_text.enabled flag (off by default). When it is disabled, inbound voice messages are ignored; text and image messages are unaffected. Enable it in .infer/config.yaml:
speech_to_text:
enabled: true # off by default
model: tiny # tiny | base | small | medium | large-v3-turboThis requires whisper.cpp and ffmpeg on the host. See Speech-to-Text for prerequisites, the full configuration reference, and model selection.
Troubleshooting
| Symptom | What to check |
|---|---|
| Bot does not respond | Verify the token is reachable: curl https://api.telegram.org/bot<TOKEN>/getMe |
| Logs show "unauthorized user" | Add the sender's chat ID to allowed_users (string, not number) |
| Bot acknowledges but never replies | The thread's session worker failed - grep the daemon log for the chat's conversation_id, and run infer headless "test" standalone to confirm the agent itself works |
| Approvals never appear | Ensure channels.require_approval: true and the relevant tool's require_approval is not explicitly set to false |
| Long replies are cut off | Telegram limit is 4096 chars per message - confirm the adapter is splitting (check daemon logs) |
channels are not enabled error | Set channels.enabled: true (or INFER_CHANNELS_ENABLED=true) - the master switch is off by default |
WhatsApp (Planned)
WhatsApp is the next channel on the roadmap. The configuration schema is already defined and reserved in the CLI:
channels:
whatsapp:
enabled: false
phone_number_id: ''
access_token: ''
verify_token: ''
webhook_port: 8443
allowed_users:
- '+1234567890'Unlike Telegram, WhatsApp will use the Meta Business API with a webhook receiver (default port 8443) rather than long-polling. Implementation is tracked in the CLI repository - keep an eye on releases.
Discord & Slack
Not yet implemented. Contributions welcome. New channels implement the small Channel interface defined in internal/domain/interfaces.go of the CLI repository:
type Channel interface {
Name() string
Start(ctx context.Context, inbox chan<- InboundMessage) error
Send(ctx context.Context, msg OutboundMessage) error
Stop() error
}Adapters that want rich approval UI (buttons, modals, etc.) can additionally implement ApprovalChannel to override the default text-based approval prompt.
Configuration Reference
All channel-level options live under channels in .infer/config.yaml:
| Key | Type | Default | Description |
|---|---|---|---|
channels.enabled | bool | false | Master switch - must be true for any channel to start |
channels.max_workers | int | 5 | Maximum messages processed concurrently across chats |
channels.image_retention | int | 5 | Number of recent images kept per session |
channels.require_approval | bool | true | Send tool-approval prompts through the channel for sensitive tools |
channels.telegram.* | - | - | See Telegram configuration |
channels.whatsapp.* | - | - | Reserved for the upcoming WhatsApp adapter |
Environment Variables
Every YAML key has an INFER_-prefixed environment variable. Lists are comma-separated.
| Variable | Maps to |
|---|---|
INFER_CHANNELS_ENABLED | channels.enabled |
INFER_CHANNELS_MAX_WORKERS | channels.max_workers |
INFER_CHANNELS_IMAGE_RETENTION | channels.image_retention |
INFER_CHANNELS_REQUIRE_APPROVAL | channels.require_approval |
INFER_CHANNELS_TELEGRAM_ENABLED | channels.telegram.enabled |
INFER_CHANNELS_TELEGRAM_BOT_TOKEN | channels.telegram.bot_token |
INFER_CHANNELS_TELEGRAM_ALLOWED_USERS | channels.telegram.allowed_users |
INFER_CHANNELS_TELEGRAM_POLL_TIMEOUT | channels.telegram.poll_timeout |
INFER_CHANNELS_WHATSAPP_ENABLED | channels.whatsapp.enabled |
INFER_CHANNELS_WHATSAPP_PHONE_NUMBER_ID | channels.whatsapp.phone_number_id |
INFER_CHANNELS_WHATSAPP_ACCESS_TOKEN | channels.whatsapp.access_token |
INFER_CHANNELS_WHATSAPP_VERIFY_TOKEN | channels.whatsapp.verify_token |
INFER_CHANNELS_WHATSAPP_WEBHOOK_PORT | channels.whatsapp.webhook_port |
INFER_CHANNELS_WHATSAPP_ALLOWED_USERS | channels.whatsapp.allowed_users |
Telemetry
The daemon pushes operational metrics over OTLP when telemetry is enabled. It reuses the same telemetry engine as the CLI agent, so the setup is identical.
Enabling telemetry
Set the following environment variables before starting the daemon:
export INFER_TELEMETRY_ENABLED=true
export INFER_TELEMETRY_OTLP_ENDPOINT=http://otel-collector:4318Or in .infer/config.yaml:
telemetry:
enabled: true
otlp:
endpoint: http://otel-collector:4318When telemetry.enabled is true and telemetry.otlp.endpoint is set, the daemon pushes all signals (metrics, traces, and logs) to the configured OTLP/HTTP collector.
Exported metrics
The daemon emits the following metrics in addition to the standard infer.* and gen_ai.* metrics from the shared telemetry engine:
| Metric | Type | Description |
|---|---|---|
infer.daemon.messages_processed | Counter | Inbound messages processed |
infer.daemon.message.duration | Histogram | Per-message processing duration |
infer.daemon.active_channels | UpDown Counter | Number of active channels |
All daemon metrics carry the resource attribute infer.execution.mode=daemon, so you can distinguish them from CLI agent sessions (infer.execution.mode=cli) in dashboards and alerts.
Environment variables
| Variable | Maps to | Description |
|---|---|---|
INFER_TELEMETRY_ENABLED | telemetry.enabled | Master switch for telemetry (default false) |
INFER_TELEMETRY_OTLP_ENDPOINT | telemetry.otlp.endpoint | OTLP/HTTP collector endpoint, e.g. http://otel-collector:4318 |
Operator deployments
When the daemon runs as an Orchestrator under the Kubernetes operator, telemetry is configured through the CRD's spec.telemetry block. The operator maps it onto the same INFER_TELEMETRY_ENABLED and INFER_TELEMETRY_OTLP_ENDPOINT variables. See Orchestrator Telemetry for the field reference and an example.
The Orchestrator workload is outbound-only (no Service), so OTLP push is the only way to get metrics out. The operator sets INFER_TELEMETRY_ENABLED=true and INFER_TELEMETRY_OTLP_ENDPOINT=<collector> on the daemon container.
For the full telemetry reference, including the standard infer.* and gen_ai.* metrics, tracing setup, and Grafana dashboards, see Observability.
Related
- CLI - overview of the
infercommand-line tool and agent mode - Configuration - full configuration system across the gateway and CLI
- Observability - metrics, tracing, and logging reference
- Telegram Channel Example - Docker Compose stack (gateway + daemon + A2A browser agent + optional VNC)
- Examples - more end-to-end recipes
