Channels
A channel is the I/O boundary — how a request gets to Zeno and how the reply goes back out. Slack today; managed via the `zeno channel` CLI subtree.
A channel is the I/O boundary of a Zeno profile: it is how the operator's request reaches the agent and how the agent's reply gets back out. The channel is not a tool the agent calls — it is the conversation itself.
Today there is exactly one channel implementation: Slack via Socket Mode. The abstraction is real — the worker has a generic Channel port — but the only adapter that ships is Slack. Discord, Telegram, WhatsApp, and email are future entries on the agent/channels-catalog.json directory.
Channel vs Connector vs Backend
Three abstractions, one CLI-first contract:
| Channel | Connector | Backend | |
|---|---|---|---|
| What it is | I/O boundary (operator ↔ agent) | MCP tool surface the agent calls | Reasoning engine (LLM) |
| Examples | Slack (today) | Linear, Sentry, GitHub App | claude-code (today) |
| Multi-instance | No | Yes (e.g. Linear acme + Linear personal) | No |
| Storage | connectors table, kind='channel' | connectors table, kind='mcp' | backend_credentials |
| CLI surface | zeno channel … | zeno connector … | zeno backend … |
Channel ≠ Connector even when the platform is the same. Slack-as-channel is the conversation Zeno listens to and replies on; if you wanted the agent to post to Slack proactively or read another channel's history, that would be a Slack-as-connector entry — and is not currently shipped.
The CLI-first contract
Every channel mutation goes through the zeno channel … CLI. The dashboard /channels page is read-only by default; every action chip there opens a modal showing the equivalent CLI command to copy and run.
Seven verbs, each documented in the CLI reference:
| Verb | Purpose |
|---|---|
list | Table of installed channels with status + last event. |
show | Detail for one channel; private fields masked, public fields unmasked. |
install | Pick a channel from the catalog and write its secrets. |
configure | Update a public (non-secret) field — e.g. --dm-owner-user-id. |
test | Probe connectivity via the catalog-declared strategy. |
rotate | Replace every required private secret atomically and re-test. |
uninstall | Destructive remove; cascade-deletes secrets in the same transaction. |
The CLI sends X-Zeno-Origin: cli on every request; the worker's API gates mutations behind that header when ZENO_API_WRITES=cli (default). The dashboard never sends the header — operator-facing surface for changes stays in the terminal.
Hot-reload via ChannelManager
The worker boots a single ChannelManager that owns every channel adapter (today: just Slack). The manager:
- Reads
connectors WHERE kind='channel' AND status='enabled'at boot and spawns each adapter. - Polls the runtime DB every 2 s. When a row's
updated_ator its secrets'updated_atadvances, the manager stops the existing adapter and re-instantiates it with the fresh credentials. Afterzeno channel rotate, the live Slack websocket disconnects and reconnects with the new tokens within one poll tick — nozeno restartneeded. - Exposes
getActiveChannel()returning the running adapter (or aNoopChannelsingleton when zero channels are installed). Cron runners + the agent orchestrator hold a stable proxy returned byasChannel()that re-resolves on every method call.
This subsumes the original credential-resolver pattern (a one-shot read at boot) and lets the operator land secret rotations live.
Catalog file
agent/channels-catalog.json is the directory the dashboard and CLI both read. Each entry declares its fields[] with two flags:
required: true→ the field must be supplied at install (or via--secret KEY=VALUEin non-TTY).public: true→ the value is stored unmasked (connector_secrets.is_public=1) and rendered without redaction.false→ masked on read, prompted with hidden input.
The Slack entry today:
{
"id": "slack",
"slug": "slack",
"name": "Slack",
"transport": "socket-mode",
"testStrategy": "slack_auth_test",
"fields": [
{ "key": "SLACK_APP_TOKEN", "required": true, "public": false },
{ "key": "SLACK_BOT_TOKEN", "required": true, "public": false },
{ "key": "dm_owner_user_id", "required": false, "public": true }
]
}testStrategy is a string discriminator the worker resolves to a probe function (slack_auth_test → call Slack auth.test). Adding a future channel = append a catalog entry + register a strategy handler + ship an adapter; none of those changes touch the CLI surface or the dashboard.
Catalog entries exist if and only if an adapter exists. There are no available: false placeholder entries — the picker shows whatever the catalog returns.
Slack-specific details
The Slack channel uses @slack/bolt in Socket Mode, which means an outbound websocket from the container to Slack. No public URL, no inbound tunnel, no Slack event subscription URL to point at the host.
The contract:
- Trigger. The agent runs once when the bot is mentioned (
@zeno ...) or DM'd. - Response. Every turn produces one Slack message back in the same channel/thread.
- Stateless per turn. No conversation memory between mentions in the MVP.
App manifest
The Slack app definition lives in the repo at infra/slack-app-manifest.json. Create a Slack app from that manifest, install it to your workspace, and run zeno channel install slack — the CLI will prompt for both tokens.
Credentials
| Token | Where it comes from | Stored as |
|---|---|---|
Bot token (xoxb-…) | "OAuth & Permissions" page of the Slack app | connector_secrets row, is_public=0, encrypted with the profile's master key |
App-level token (xapp-…) | "Basic Information" → App-level tokens, with connections:write scope | Same |
DM owner user id (Uxxx) | Optional config — restricts DMs to one user | connector_secrets row, is_public=1, plaintext |
You never paste these into a .env file by hand. The CLI puts them in the runtime DB; the dashboard surfaces masked values for the private fields.
Boot without Slack
The worker boots without Slack credentials. When zero channels are installed, the manager's getActiveChannel() returns a NoopChannel that quietly drops outbound calls; the dashboard stays reachable for the operator to install. As soon as zeno channel install slack lands the row, the manager picks it up on the next poll tick and the real SlackChannel takes over — no restart needed.
What channels are not
- Channels are not memory. The MVP runs each turn fresh. Persisting cross-turn memory is a future concern attached to its own spec, not something to assume.
- Channels are not multi-instance. A profile has one Slack workspace, period. If you operate two, run two profiles.