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:BridgeTransporthas none (it dispatches synchronously), Slack/Telegram each keep a per-channelChannelQueue.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;
}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.
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.
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
data/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.