Iris Docs

Writing a Transport

The ChannelTransport contract — how to plug a new chat platform (Discord, WhatsApp, ...) into Iris with zero engine edits.

"How do I add Discord/WhatsApp?" — implement ChannelTransport (src/transport/types.ts) and register it in main.ts. The engine (src/engine/index.ts, agent.ts) never imports a concrete transport; it only knows the shapes in transport/types.ts. A correctly-implemented transport requires zero engine edits — that's the acceptance bar, not an aspiration.

Four things exist today: SlackBot, TelegramBot, BridgeTransport (headless — sub-agents and the internal API), WebTransport (the built-in browser UI, see Web UI). Read src/transports/bridge/bridge-transport.ts first — it's the smallest complete implementation and proof the interface isn't Slack-shaped (posting is a no-op; replies accumulate for a session request to consume instead of hitting a real API).

The ChannelTransport interface

interface ChannelTransport {
	transportId: string;
	promptProfile: TransportPromptProfile;
	stopCommandHint: string;
	start(): Promise<void> | void;
	stop(): Promise<void> | void;
	ownsChannel(channelId: string): boolean;
	getChannels(): ChannelInfo[];
	getUsers(): UserInfo[];
	postMessage(channelId: string, text: string): Promise<string>;
	updateMessage(channelId: string, messageId: string, text: string): Promise<void>;
	enqueueEvent(event: TransportEvent): boolean;
	createContext(event: TransportEvent, state: ChannelState, isEvent?: boolean): MessageContext;
}
  • transportId — a short lowercase tag ("slack", "telegram", "bridge", "web"). Stamped onto every MessageContext this transport creates; agent.ts uses it to look up the prompt profile via getPromptProfile(ctx.transportId) and throws if none is registered — get this wrong and every run on the new transport fails at prompt-build time.
  • ownsChannel(channelId) — how inbound API calls and synthetic events (POST /sessions/:id/message, scheduled events) find their transport. The registry in main.ts is checked in order (transports.find(t => t.ownsChannel(id))), so ownership must be based on a channel-id shape that doesn't collide with another transport's — Telegram claims a tg- prefix, Bridge is the catch-all fallback ("everything that isn't Telegram"). If your new transport's channel IDs could collide with an existing prefix, pick a distinguishing prefix (dc- for Discord, wa- for WhatsApp) and register it before Bridge in the main.ts array (see Registering it).
  • postMessage / updateMessage — send a new message and edit an existing one (by the ID postMessage returned). These back the "Thinking…" placeholder pattern every transport uses: post it, then edit it in place with the final reply's first chunk once the run completes.
  • enqueueEvent — queue an inbound event for processing; returns false when the channel's queue is full (the caller logs a warning and drops the event — see Queueing and overflow for the shared 5-message-per-channel convention every transport follows). Own your queue: BridgeTransport, Slack, and Telegram each keep a per-channel ChannelQueue so two events on the same channel never dispatch concurrently.
  • createContext — build the MessageContext (below) for one inbound event. This is where transport-specific reply plumbing (message chunking, thread anchoring, typing indicators) gets wired up.

MessageContext — chunking and threading are the transport's job

interface MessageContext {
	transportId: string;
	message: { text, rawText, user, userName?, channel, ts, attachments };
	channelName?: string;
	channels: ChannelInfo[];
	users: UserInfo[];
	respond: (text: string, shouldLog?: boolean) => Promise<void>;
	replaceMessage: (text: string) => Promise<void>;
	respondInThread: (text: string) => Promise<void>;
	setTyping: (isTyping: boolean) => Promise<void>;
	uploadFile: (filePath: string, title?: string) => Promise<void>;
	setWorking: (working: boolean) => Promise<void>;
	deleteMessage: () => Promise<void>;
	getAccumulatedText: () => string;
	onToolEvent?: (event: ToolEvent) => void;
	setStatus?: (label: string) => Promise<void>;
}

The engine calls respond/replaceMessage/respondInThread with whole, unsplit text — it never chunks, truncates, or otherwise looks at platform message-length limits. That's entirely the transport's responsibility inside createContext's closures:

  • Slack chunks at SLACK_MAX_LENGTH, splitting on natural newline boundaries (splitIntoChunks in slack.ts), posts chunk 1 in place of the "Thinking…" placeholder and chunks 2+ as thread replies, and re-splits at half the chunk size (down to a 1,000-char floor) if Slack still rejects a chunk with msg_too_long — budgeted against the HTML-escaped length, since that's what Slack enforces against.
  • TransportPromptProfile.maxMessageChars is the number the model is told to target (so replies rarely need splitting at all) — the transport's own hard chunking logic is the backstop for when the model ignores that budget or a single tool result is unavoidably huge.
  • BridgeTransport does no chunking at all: respond just concatenates into an in-memory accumulator (getAccumulatedText()), because the consumer (POST /sessions/:id/message) wants one string, not a platform-shaped sequence of posts.

Threading is the same story: respondInThread is "post a supplementary, lower-priority detail" (errors, usage summaries) — Slack posts it as an actual thread reply; a transport with no threading concept (Bridge, in effect) can just fold it into the same accumulator. There's no shared chunking/threading helper in the engine to call into — each transport owns this because platform message-length limits and thread semantics are genuinely platform-specific; don't try to hoist it up.

onToolEvent is optional and additive: transports that can render live-updating structure (the web UI's tool cards) implement it to get the raw ToolEvent instead of the flattened markdown string respond(...) already receives for the same tool_execution_start/end moments. Slack and Telegram don't implement it — mrkdwn/plain-text has nothing better to do with structure than flatten it — and that's a legitimate, permanent choice, not a gap to fill later.

setStatus is also optional: it edits the run's placeholder message in place with a short status line (e.g. _→ running bash..._), giving a live "Iris is doing something" signal without posting a new message per tool call. Slack and Telegram both implement it by calling their own updateMessage against the placeholder id createContext already tracks — no new platform API needed. This is what backs the verbose off (default) mode described in Channel Modes: agent.ts calls setStatus on every tool_execution_start regardless of the verbose setting, and only gates the heavier per-tool-call/thinking detail dump behind it. Bridge has no single editable placeholder to update, so it simply doesn't implement setStatus — same optional-capability pattern as onToolEvent.

TransportPromptProfile — no platform text in the engine

interface TransportPromptProfile {
	transportId: string;
	identityLine: string;
	formattingSection: string;
	directorySection: (channels: ChannelInfo[], users: UserInfo[]) => string;
	silentNote: string;
	attachNote: string;
	attachmentsTagName: string;
	maxMessageChars: number;
}

buildSystemPrompt in src/engine/agent.ts composes the system prompt entirely from these fields — grep -ri discord src/engine/agent.ts must return nothing once your transport lands, same invariant IRIS-49 established for Slack. Register the profile at construction time with registerPromptProfile(this.promptProfile) (see BridgeTransport's constructor); the registry is a module-level Map keyed by transportId, so construct the transport before any message on it can be dispatched.

Field-by-field, using Telegram's profile (telegram.ts) as the concrete example:

  • identityLine — one sentence: "You are Iris, a Telegram-connected orchestrator for specialized sub-agents."
  • formattingSection — the platform's markup dialect, spelled out precisely enough the model doesn't guess wrong (Telegram's is a Markdown subset converted to HTML server-side; the profile explicitly calls out what does not convert — single *asterisks* — so the model doesn't use GitHub-flavored Markdown by habit. [markdown](links) are still discouraged in the prompt, but toTelegramHtml() handles them defensively either way: an http(s) URL becomes a real <a href>, and anything else — most commonly the model wrapping an attachment's filename in link syntax, since the file itself already went out separately via the attach tool — is reduced to plain label text instead of leaking raw [text](url) bracket syntax into the chat).
  • directorySection(channels, users) — a function, not a static string, because it renders the live channel/user list into ID↔name mapping guidance for that run.
  • silentNote / attachNote — what [SILENT] and the attach tool do on this platform, in prose the model conditions on.
  • attachmentsTagName — the XML-ish tag wrapping non-image attachment paths in the user prompt (telegram_attachments, slack_attachments).
  • maxMessageChars — see chunking above.

Registering it

main.ts constructs transports from env and pushes them into one array in registry order — that order is also the fallback preference order for session operations that don't already know which transport owns a channel (Slack, then Telegram, then Bridge today):

const transports: (ChannelTransport & SessionInjector)[] = [];

const discordBot = DISCORD_BOT_TOKEN
	? new DiscordBot(handler, { token: DISCORD_BOT_TOKEN, workingDir })
	: null;
if (discordBot) transports.push(discordBot);

Gate construction on the presence of whatever credential the platform needs, exactly like Slack/Telegram/the web UI do — a transport with no token configured must cost the install nothing (no connection attempt, no log noise), the same "off by default" contract IRIS_WEBUI_PORT follows. SessionInjector (injectSessionMessage / resetSessionContext) is a second, smaller interface required only if the transport should be reachable via the internal session API (api.ts) — BridgeTransport implements it because sub-agent escalation depends on it; a chat-only transport that never backs a session can skip it. injectSessionMessage takes an optional attachments: [{local}] — pass it straight through to the event you dispatch (that is all the engine reads), and log it with the user message the way the shipped transports do.

If your transport wants to watch a run it doesn't own — because another transport is running the turn on a channel your clients are subscribed to — register a ChannelObserver with engine/channel-observers.ts rather than hooking a transport's internals. It is a passive, transport-agnostic mirror of run events (thinking/status/tool/final/file), skipped entirely when nothing is watching, and an observer that throws cannot fail the run. See Web UI for a worked example.

Caveat: the "register before Bridge" rule above is not consistently followed by the shipped transports today — WebTransport is pushed after BridgeTransport in main.ts even though Bridge's ownsChannel is a catch-all (!channelId.startsWith("tg-")) that also matches WEBUI-* channel IDs. In practice this hasn't bitten Web because it never goes through the shared transports.find(t => t.ownsChannel(id)) lookup (its messages travel over its own WebSocket connection, not the events-file watcher or channel-addressed API routes) — but a channel-addressed API call or synthetic event against a WEBUI-* channel would resolve to Bridge's no-op postMessage instead of Web's real one. Don't copy Web's position in the array as precedent; follow the stated rule (register before Bridge) and verify with a synthetic event or a channel-addressed API call against your new transport's channel IDs, not just a live chat message.

Channel-mode dispatch is opt-in, not part of the contract

The six named channel modes (dm/admin/thread/ interactive-thread/leads/passthrough) and the resolveDispatch() pipeline behind them (src/engine/dispatch.ts, dispatch-config.ts) are expressed against fully generic shapes (InboundMessage, DispatchConfig) specifically so any transport can drive them — but today only Slack does. Telegram and the web UI have their own simpler event handling and don't read meta/channels.json at all. Implementing ChannelTransport does not require wiring up channel-mode dispatch; do it only if the new platform genuinely needs per-channel mode configuration (mention-gating, passthrough relay, admin commands). If you do, drive it through resolveDispatch rather than re-deriving the same trigger/container logic a second time — that duplication is exactly the bug class IRIS-54 killed.

Checklist before opening a PR

  • transportId is stamped on every MessageContext this transport creates, and registerPromptProfile is called at construction time.
  • ownsChannel can't collide with an existing transport's channel-ID shape; the new transport is pushed into main.ts's array before Bridge (the catch-all fallback).
  • No platform-specific string literals leak into src/engine/agent.ts or src/engine/index.ts — everything platform-flavored lives on TransportPromptProfile or inside this transport's own file.
  • Message chunking and thread anchoring are implemented in createContext's closures, budgeted against whatever length limit the platform actually enforces (escaped/rendered length, not raw).
  • Attachments are saved via resolveChannelDir/resolveChannelPath (src/engine/store.ts) — never a hand-built workingDir + channelId path.
  • Zero-config-cost when disabled: no token/port set means no connection attempt and no log noise.
  • iris-runtime/CHANGELOG.md ([Unreleased]) and this docs page (or a new one, linked from docs/meta.json) are updated in the same PR — see the PR review checklist.
Edit this page on GitHub