Channel Modes
Configure how Iris behaves per Slack channel — from mention-only to full passthrough.
How Iris behaves in a Slack channel is configured per channel in
<workspace>/meta/channels.json. Keys are channel IDs; prefix wildcards like D*
are supported. An exact channel ID always wins over wildcards; when several
wildcards match, the longest prefix wins (e.g. DA* beats D*).
| Mode | Behavior |
|---|---|
dm | Default. Responds in DMs; channels need an @iris mention |
admin | Identical to dm — kept as a permanent alias. Control commands used to be what set it apart; they now work in dm channels too |
thread | Only responds inside registered session threads (sessions created via the API) |
interactive-thread | A top-level message opens a session; replies in that thread continue it without further mentions |
leads | Every top-level message triggers Iris — no mention needed |
passthrough | Every message is forwarded to an external HTTP endpoint; the reply is posted back. Iris's LLM never runs |
requireMentionForTopLevel (per channel entry) gates what a top-level channel
message does in interactive-thread and passthrough channels: when set, only
an @iris mention opens a session / gets forwarded, and plain top-level messages
are logged but otherwise ignored.
These six names are the only supported, documented surface — copy one of the recipes below. Underneath, all six are the same dispatch pipeline running with different settings; see Under the hood if you're curious how they relate, but don't configure a channel with raw settings instead of a named mode.
{
"C0XXXXXXX": { "mode": "interactive-thread", "requireMentionForTopLevel": true },
"C0YYYYYYY": {
"mode": "passthrough",
"url": "https://example.com/webhook",
"secretName": "MY-WEBHOOK-KEY",
"payload": { "sender_id": "{{sender_id}}", "message_text": "{{text}}" },
"replyPrefix": "*Bot:* "
},
"D*": { "mode": "dm" }
}Control commands
Three commands control a run and its context rather than talking to Iris. Each
works with or without a leading slash — stop and /stop are the same command.
| Command | Effect |
|---|---|
stop | Abort the run in flight (_Nothing running_ if idle) |
compact | Summarise the context into a compaction entry |
reset (alias clear) | Clear the conversation history |
They are read as commands wherever the message is unmistakably meant for Iris:
- A DM, or an explicit
@iris stop— every mode. This is not a permission boundary and never was: anyone who can mention Iris can have her run arbitrary bash, which strictly dominates stop/compact/reset. - A reply inside a session thread (
thread/interactive-thread) — every reply there is addressed to Iris by construction, so no mention is needed. The command acts on that session's run, not the channel's. - Bare top-level channel text (no mention, no thread) —
dmandadminchannels only.leadsis excluded because its top-level traffic is third-party feeds, where an email bot's "STOP" is an unsubscribe keyword rather than a command. - A native Slack slash command —
/stop,/compact,/clear,/reset,/verbose on|off, in anydm/admin/leadschannel. They are not registered for you: add them to your Slack app (see Setup), or Slack rejects a typed/stopclient-side and Iris never sees it. Slash commands carry no thread, so in athread/interactive-threadchannel they're refused with a note to reply in the session's thread instead.
Never a command: a thread reply in a chat-container channel that is neither a
mention nor a DM (a mid-conversation "stop" between two people can't wipe the
channel out from under them), and anything in a passthrough channel, where
every message is forwarded verbatim and Iris keeps no context to control.
admin mode is now identical to dm — control commands were the only thing
that distinguished them. It remains a permanent alias, so existing
channels.json files keep working unchanged.
On Telegram these are the bot commands /stop, /compact, /reset, /clear
and always work — Telegram has no channel-mode concept.
Verbose tool output
By default (IRIS_VERBOSE_TOOLS unset), a run shows one status line that
updates in place as Iris works (e.g. _→ running bash..._), then gets
replaced by the final answer — no per-tool-call thread replies (Slack) or
flat messages (Telegram), no chain-of-thought dump, no per-run cost summary.
Toggle it with verbose on / verbose off / verbose status on Slack (the
/verbose ... spelling works too), or /verbose on|off|status on Telegram.
It's a UX preference rather than a destructive action, so it has never been
gated by channel mode at all: it works from any DM or explicit @iris mention
in any mode. The setting persists per channel (in that channel's
settings.json) until toggled again; IRIS_VERBOSE_TOOLS=true in /iris/.env
changes the default for channels that haven't set their own override.
This toggle only controls what gets posted back into the chat transport.
Tool-call invocations (name, label, and formatted arguments) are always
written to the console log (journalctl -u iris) regardless of the
setting — formatToolArgs() in iris-runtime/src/engine/log.ts formats
the arguments and logToolStart() uniformly indents every line of the
joined output.
Reply context (Telegram)
Replying to an earlier message includes that message's text (or caption, or a
content-type note like [a photo] for media) prepended to what Iris receives,
so a follow-up like "this one?" resolves against the message it replied to.
Passthrough configuration
Passthrough channels forward every message — top-level channel messages,
thread replies, @iris mentions, and DMs — to url as a JSON POST and post the
endpoint's reply back into the thread. Nothing is interpreted by Iris herself:
even stop / compact / reset are forwarded verbatim (and a slash command is
refused outright), and scheduled events cannot target a passthrough channel.
payload— a JSON template for the request body. String values may use placeholders, substituted recursively:{{text}},{{user_id}},{{user_name}},{{user_handle}}(lowercased, dot-separated),{{sender_id}},{{channel}},{{ts}}. Default:{ "text": "{{text}}", "user": "{{user_name}}", "sender_id": "{{sender_id}}" }secretName— API key resolved through theget-secretskill (cached for the process lifetime); sent as anX-API-Keyheader. Falls back to thePASSTHROUGH_API_KEYenv var.replyPrefix— optional prefix prepended to replies posted back to the thread.
The endpoint's response is read as JSON; the first of response, text, or
error is posted back.
Queueing and overflow
Each channel queues at most 5 pending messages for the LLM. This includes
leads channels: under burst load, messages beyond the cap are logged with a
warning but do not trigger a run — no notice is posted into the channel itself,
which is often an external-facing feed. The full message text is always
preserved in the channel's log.jsonl, so no lead is lost; only the automated
response is skipped.
Sessions
thread and interactive-thread modes are built on sessions — durable
conversation containers created via the internal API or by a
top-level mention in an interactive-thread channel. Session state lives on disk
and survives restarts; routes are rebuilt from sessions.json at startup.
Under the hood
The six named modes are three primitives plus orthogonal flags, all resolved
by one dispatch pipeline in the engine (src/engine/dispatch.ts and
dispatch-config.ts) instead of each mode re-implementing its own slice of
routing logic. This dispatch pipeline is currently wired up for Slack only;
see Writing a Transport
if you're adding a new chat platform and deciding whether it needs the same
wiring.
| Primitive | Behavior |
|---|---|
chat | Channel-context LLM run — replies land in the channel itself |
sessions | Per-thread session LLM run — replies are scoped to a durable session (see Sessions) |
relay | Webhook forward — Iris's LLM never runs |
| Flag | Values | Meaning |
|---|---|---|
trigger | mention | all-top-level | api-only | What opens/continues a container organically: an explicit @iris mention only, any top-level message, or nothing (only a pre-existing, API-created session continues) |
adminCommands | boolean | Bare top-level channel text (no mention, no thread) is read as a stop / compact / reset command. Only governs that ambiguous shape — a DM or an explicit @iris mention is a command in any chat container regardless of the flag |
acceptBotMessages | boolean | Bot/integration messages are admitted as triggers, not filtered out |
replayMissed | boolean | Pre-startup top-level messages are replayed instead of skipped |
The mapping, exactly as implemented — this is also the legacy-alias table:
channels.json only ever stores one of these six names, expanded to the
primitive shape on load, and the six names remain supported forever so no
existing config ever needs to change.
| Mode | container | trigger | adminCommands | acceptBotMessages | replayMissed |
|---|---|---|---|---|---|
dm | chat | mention | ✓ | — | — |
admin | chat | mention | ✓ | — | — |
leads | chat | all-top-level | — | ✓ | ✓ |
thread | sessions | api-only | — | — | — |
interactive-thread | sessions | mention if requireMentionForTopLevel, else all-top-level | — | — | — |
passthrough | relay | mention if requireMentionForTopLevel, else all-top-level | — | — | — |
A mention or a DM always reaches its container regardless of trigger — the
flag only gates plain top-level chatter with no explicit address to the bot.
This mapping is an implementation detail, not a second configuration surface:
channels.json only ever takes a named mode (+ requireMentionForTopLevel
- passthrough's
url/payload/secretName/replyPrefix), which the engine silently expands into this shape. There is no way to configure a channel with rawcontainer/trigger/flag values directly, and there won't be — new behavior gets a new named mode (or a new flag on an existing one), documented here as a recipe, not a raw combination for callers to assemble themselves.
Channel workspace layout
Real Telegram and Slack channels live two levels under the data root:
data/telegram/tg-<id>/ for Telegram chats and data/slack/<id>/ for Slack
channels. The workspace root is resolved from the explicit working directory
passed through runner creation — not inferred from the channel directory's
depth — so custom model providers (models.json), memory (MEMORY.md), and
skills are discovered correctly for real conversations, exactly as they are for
synthetic session channels.