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 everyMessageContextthis transport creates;agent.tsuses it to look up the prompt profile viagetPromptProfile(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 inmain.tsis 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 atg-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 themain.tsarray (see Registering it).postMessage/updateMessage— send a new message and edit an existing one (by the IDpostMessagereturned). 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; returnsfalsewhen 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-channelChannelQueueso two events on the same channel never dispatch concurrently.createContext— build theMessageContext(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 (splitIntoChunksinslack.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 withmsg_too_long— budgeted against the HTML-escaped length, since that's what Slack enforces against. TransportPromptProfile.maxMessageCharsis 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.BridgeTransportdoes no chunking at all:respondjust 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, buttoTelegramHtml()handles them defensively either way: anhttp(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 theattachtool — 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
-
transportIdis stamped on everyMessageContextthis transport creates, andregisterPromptProfileis called at construction time. -
ownsChannelcan't collide with an existing transport's channel-ID shape; the new transport is pushed intomain.ts's array before Bridge (the catch-all fallback). - No platform-specific string literals leak into
src/engine/agent.tsorsrc/engine/index.ts— everything platform-flavored lives onTransportPromptProfileor 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-builtworkingDir + channelIdpath. - 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 fromdocs/meta.json) are updated in the same PR — see the PR review checklist.