Skip to content

Telegram Remote Steering — Developer Architecture

Audience: developers extending, debugging, or maintaining the --telegram channel. For the end-user pair/use/troubleshoot guide see telegram-remote-steering.md. For the attachment feature (inbound media + outbound send_attachment) see telegram-attachments.md. For the security model (TOFU, token = full control, multi-tap SEC, file handling) see telegram-threat-model.md.


1. Overview

--telegram mounts a TelegramChannel alongside the local InkChannel so a paired operator can drive a long-running fleet from a phone. The bot is a thin steering surface — every inbound text or tapped button is dispatched through the same command/coordinator pipeline the local REPL uses. The feature lives in three subtrees:

  • src/bot/ — the grammy-isolated transport, allowlist gate, pairing, egress pipeline (PII → MD2 escape → chunker → outbound queue), interaction registry (ask nonces), multi-tap registry, permission memory, dashboard renderer.
  • src/channels/ — the Channel contract, the ChannelHub fan-out shell, the InkChannel and TelegramChannel implementations.
  • src/cli/telegramSetup.ts + src/cli/telegramInboundDispatch.ts — the CLI-side wiring that composes everything and bridges inbound updates to the coordinator.

The only module allowed to import grammy is src/bot/transport.ts; every other file talks to the bot through the BotTransport interface. This keeps the surface area for the grammy upgrade or replacement tiny.


2. Architecture at a glance

Inbound: user taps a button → ask resolves

Inbound callback flow

(Source: diagrams/telegram/inbound-callback.mmd)

Inbound: user types text → coordinator

Inbound text flow

(Source: diagrams/telegram/inbound-text.mmd)

Outbound: coordinator → Telegram

Outbound flow

(Source: diagrams/telegram/outbound.mmd)


3. Key components and where they live

ComponentPathPurpose
Channel interfacesrc/channels/Channel.tsContract every UI surface implements (askUser, permission, notify, pushUpdate, dispose).
ChannelHubsrc/channels/ChannelHub.tsOwns attach/dispose bookkeeping and the parallel askUser / handlePermissionRequest race; aborts losers via shared AbortController.
InkChannelsrc/channels/InkChannel.tsxInk/React rendering of dialogs, dashboards, notifications. Honours req.signal to dismiss dialogs when fan-out aborts (PR #246).
TelegramChannelsrc/channels/TelegramChannel.tsBot-side rendering: prompt → inline keyboard, freeform → reply window, multi-tap → confirm button.
BotTransportsrc/bot/transport.tsSOLE grammy importer. Owns pollOnce, adaptUpdate, onUpdate, sendMessage, answerCallbackQuery, dedupe LRU, exponential backoff.
InteractionRegistrysrc/bot/interactions.ts4-byte hex nonce ledger; resolves handleCallback(nonce, chatId, fromId, value, authorizedPairings) to 'ok' | 'unknown-nonce' | 'wrong-auth' | 'expired' | 'already-answered'.
MultiTapConfirmRegistrysrc/bot/multiTapConfirm.tsThree-tap SEC confirmation state.
OutboundQueue + OutboundLedgersrc/bot/outboundQueue.ts, src/bot/outboundLedger.tsPer-chat serialized send queue with msgKey dedupe + retry ledger.
Egress pipelinesrc/bot/egressPipeline.tsPII redact → MD2 escape → chunk → enqueue. Owns the noParseMode fallback path.
PiiRedactorsrc/intel/PiiRedactor.tsStateless secret/PII scrubber reused for both intel and Telegram egress.
toTelegramMdsrc/bot/telegramMd.tsMarkdownV2 escape for the 18 reserved characters.
chunkForTelegramsrc/bot/chunker.tsSafe-boundary chunk splitter (paragraph → sentence → 4096-byte fallback).
Allowlist gatesrc/bot/inbound.tsgateUpdate(meta, config) — central auth decision used by every inbound path.
Pairingsrc/bot/pairing.ts/start <token> TOFU pairing; persists to ~/.fleet/telegram.json.
setupTelegramRuntimesrc/cli/telegramSetup.tsComposition root — wires transport + queue + registries + channel, returns { channel, transport, wireInbound, stop }.
wireInboundTransportsrc/cli/telegramInboundDispatch.tsRegisters transport.onUpdate; gates and branches by update.kind.
createAuthorizedDispatchHandlersrc/cli/telegramInboundDispatch.tsDetaches dispatchTelegramText from the poll loop after a synchronous notifyExternalInput (PR #244).
dispatchTelegramTextsrc/cli/telegramInboundDispatch.tsRoutes text to slash-command handler or coordinator.sendPrompt.
CoordinatorEngine.notifyExternalInputsrc/coordinator/CoordinatorEngine.tsSynchronous fan-out to event handlers (e.g. TranscriptBridge) so the local Ink transcript shows [via Telegram] … immediately.
TranscriptBridgesrc/repl/TranscriptBridge.tsSubscribes onExternalInput and appends a via Telegram line to the Ink transcript.
AttachmentStoresrc/bot/attachmentStore.tsPersistent inbound-attachment storage rooted at <home>/.fleet/attachments/. Owns filename sanitization, collision suffixing, atomic .partial → final rename, symlink-rejecting load.
handleInboundAttachmentssrc/cli/telegramInboundDispatch.tsDetached per-update pipeline: transport.downloadFileAttachmentStore.saveFromStreamnotifyExternalInput echo → coordinator.sendPrompt(caption + bullets).
send_attachment tool + AttachmentSendersrc/coordinator/tools/sendAttachment.tsCoordinator tool that pushes a local file to every paired chat via a transport-agnostic AttachmentSender contract. kind=auto resolves by extension.
Bot index/barrelsrc/bot/index.tsRe-exports the public surface; never re-exports grammy types.

4. The Channel interface contract

Every channel implements src/channels/Channel.ts:

ts
interface Channel {
  readonly name: ChannelName;
  readonly capabilities: ChannelCapabilities;

  attach(): Promise<void>;     // idempotent
  dispose(): Promise<void>;    // idempotent; settles pending asks as channel-disposed
  isAttached(): boolean;

  askUser(req: AskUserRequest): Promise<AskUserResult>;
  handlePermissionRequest(req: PermissionRequest): Promise<PermissionDecision>;

  pushUpdate(snapshot: DashboardSnapshot): Promise<void>;
  notify(level, message, options?): Promise<void>;

  declaresSupport(commandName: string): boolean;  // pure, sync
  onEvent?(listener): () => void;
}

Critical contracts

  1. askUser MUST honour req.signal. ChannelHub races every supporting channel under a single AbortController and calls abort.abort() the moment one resolves. Channels that ignore the signal leak orphaned UI (e.g. the Ink dialog left open after Telegram answered — PR #246). The listener pattern lives in InkChannel.tsx at req.signal.addEventListener('abort', onAbort, { once: true }).

  2. dispose() must be idempotent and settle every in-flight ask as { cancelled: true, reason: 'channel-disposed' }. The hub calls dispose on shutdown and on unregister.

  3. notify(level, message) is fire-and-forget from the caller's POV; the channel may debounce / queue / drop, but resolution does not imply delivery. It is awaited by the hub only so debounce flushes can complete before shutdown.

  4. declaresSupport(commandName) is pure. Used by isCommandSupported to filter UI-only commands (/scroll, /copy, …) out of Telegram dispatch.

  5. No method may throw synchronously. Errors surface via the returned Promise's reject path.


5. Inbound dispatch flow

5.1 The poll loop and why it does NOT await user-interactive handlers

BotTransport.pollOnce (src/bot/transport.ts:602) serially awaits each adaptUpdate result through the registered onUpdate handler. Before PR #244, the handler awaited dispatchTelegramText directly — which awaited the command handler — which could park on coordinator.askUser waiting for a button tap. Result: the poller stalled until the user answered, then Telegram delivered a burst of queued updates on the next getUpdates.

PR #244 introduced createAuthorizedDispatchHandler (src/cli/telegramInboundDispatch.ts:431), which:

  1. Calls coordinator.notifyExternalInput('telegram', text) synchronously so the local Ink transcript renders [via Telegram] … before any detached work begins. This preserves transcript ordering when the dispatched command parks on an askUser.
  2. Then void dispatchTelegramText(text, deps).catch(...) — fully detached.

Ordering across messages is still preserved by Telegram's monotonically increasing update_id; we just no longer serialize handler execution.

5.2 callback_query is a separate branch

wireInboundTransport (src/cli/telegramInboundDispatch.ts:294) branches on update.kind BEFORE calling routeUpdate:

  • kind === 'callback_query' and decision.allow === true → directly call channel.handleCallbackQuery({ callbackQueryId, callbackData, chatId, fromId, messageId }).
  • everything else → channel.routeUpdate(decision, ctxShim).

The callback path is intentionally not detached. It only resolves an already-registered ask — it cannot park, and the user is waiting for the button to stop spinning, so we want the answerCallbackQuery round-trip to happen before we return to pollOnce.

5.3 End-to-end sequence (text command)

  1. bot.api.getUpdates returns one Update.
  2. adaptUpdate projects it to a BotInboundUpdate (or null for kinds we don't handle).
  3. transport.onUpdate(handler) invokes the handler registered by wireInboundTransport.
  4. Handler builds an InboundCtxShim, reads telegram.json fresh, computes meta via extractUpdateMeta, then decision = gateUpdate(meta, config).
  5. Branch:
    • kind === 'callback_query'channel.handleCallbackQuery(…)interactions.handleCallback(nonce, chatId, fromId, value, authorizedPairings).
    • else → channel.routeUpdate(decision, ctxShim).
  6. For authorized messages, routeUpdate invokes the channel's onAuthorizedUpdate hook, which is bound to createAuthorizedDispatchHandler:
    1. coordinator.notifyExternalInput('telegram', trimmed) (sync).
    2. void dispatchTelegramText(text, deps) (detached).
  7. dispatchTelegramText classifies the input and either:
    • runs a local command and notifys the result, or
    • calls coordinator.sendPrompt(text) and lets the coordinator's onMessage flow back through the outbound path.

6. Outbound flow (coordinator → Telegram)

telegramSetup.wireInbound subscribes the channel to coordinator.addEventHandler({ onMessage }). Only end-of-turn onMessage content is forwarded — per-token onStreamDelta is intentionally NOT forwarded (it would blow through Telegram's per-chat rate limit in seconds).

Each forwarded message runs through egressPipeline.processOutboundMessage (src/bot/egressPipeline.ts):

  1. PII redactionPiiRedactor.redact(text).
  2. MarkdownV2 escapetoTelegramMd(redacted). If this throws (corrupt input or a redactor regression), the pipeline collapses to FORMAT_FAILURE_PLACEHOLDER rather than leaking unescaped content.
  3. ChunkingchunkForTelegram(escaped) splits on paragraph/sentence boundaries before the 4096-byte fallback.
  4. Enqueue — each chunk lands in OutboundQueue with a sequential msgKey so the ledger can dedupe and retry without re-ordering.
  5. Send — the queue calls the sendText adapter from telegramSetup.ts:204, which forwards { parseMode: 'MarkdownV2' } to BotTransport.sendMessage.

6.1 The MD2 fallback (noParseMode) — PR #240

A composed prompt containing reserved MD2 characters can survive the escaper (e.g. an off-by-one in a custom prompt template) and produce a Telegram HTTP 400 (can't parse entities). Before PR #240, the egress pipeline retried with the SAME parse_mode flag → infinite 400 loop and a red-faced bot.

The fix: the fallback path enqueues a fresh payload with { noParseMode: true }. The sendText adapter (telegramSetup.ts:211) honours that flag and calls transport.sendMessage(id, text) WITHOUT parseMode, sending the redacted-but-unescaped text as plaintext. The fallback uses a snapshot of the redacted text taken before MD2 escaping (egressPipeline.ts:162-167) so it can never leak unredacted content nor re-trigger the escape failure.

6.2 replyMarkup (inline keyboards) — PR #242

BotSendMessageOptions.replyMarkup is the typed surface that flows through to grammy's reply_markup. Before PR #242, choice prompts shipped as a numbered text fallback because the adapter didn't pass replyMarkup to the transport. The fix wires it explicitly in sendChoicePrompt and sendMultiTapPrompt (telegramSetup.ts:217, :264). Inline-keyboard button text is NOT MD2-escaped — Telegram treats it as literal plaintext and rendering escape backslashes would corrupt the label.


7. Cross-channel fan-out (ChannelHub.askUser)

ChannelHub.askUser (src/channels/ChannelHub.ts:175) fires every attached channel that supportsKind(kind) in parallel, under a single AbortController. The race rules:

  • First valid (non-cancelled) answer wins. The hub resolves its caller with that answer.
  • Winner calls abort.abort() (ChannelHub.ts:214). Every loser's req.signal fires; their askUser implementation MUST detect this and settle with { cancelled: true, reason: 'user-cancel' }.
  • notifyLosers(participants, winner) (ChannelHub.ts:490) fires an info notification to every loser: "Prompt answered via <winner.name>.".
  • All-cancelled precedence: when every channel returns cancelled, the hub picks the highest-priority reason via CANCEL_PRIORITY: user-cancel (0) > channel-disposed (1) > timeout (2).
  • Default timeouts live in ASK_TIMEOUTS_MS: freeform 10 min, choice / searchable-choice 5 min. AskUserRequest.timeout overrides.

PR #246 was the regression where InkChannel ignored the abort signal: Telegram resolved, the hub aborted, but the Ink dialog stayed visible and swallowed the operator's next keystroke. Lesson: every channel implementation MUST wire req.signal.addEventListener('abort', dismiss, { once: true }).

The same race-and-abort pattern applies to handlePermissionRequest (ChannelHub.ts:282), with a 2-minute fail-closed timeout (PERMISSION_PROMPT_TIMEOUT_MS).


8. Pairing and auth

TOFU pairing

The first /start <token> whose token matches the pending pairing claims the (chatId, fromId) pair. completePairing (src/bot/pairing.ts) persists the result to ~/.fleet/telegram.json. The file is the durable source of truth; the runtime re-reads it on every inbound update (wireInboundTransport calls readTelegramConfig(home) per update) so agents-fleet bot pair / bot unpair from another terminal take effect without re-attaching the channel.

Multi-pairing relaxed auth (PR #243)

InteractionRegistry.handleCallback (src/bot/interactions.ts:227) accepts an optional authorizedPairings: ReadonlyArray<{ chatId, fromId }> arg. When supplied, a callback whose (chatId, fromId) matches any paired user is treated as authorized — not only the user who originated the ask. This lets two paired phones share fleet control without one phone's button taps silently dropping as wrong-auth. The nonce + chat + auth gate still holds: only paired users in the active pairing list can resolve any nonce.

Runtime promotion (PR #238)

A freshly paired user immediately receives coordinator notifications because createPairingHandler (telegramInboundDispatch.ts:369) calls channel.markPaired() on a successful /start. Before PR #238 the channel stayed in its unpaired no-op posture until restart and the user saw their own pairing confirmation but no further output.


9. Diagnostics and debugging

AGENTS_FLEET_TG_DEBUG=1

PR #243 added an opt-in stderr logger in setupTelegramRuntime:

ts
const effectiveLogger =
  deps.logger ??
  (env.AGENTS_FLEET_TG_DEBUG
    ? (msg) => { try { process.stderr.write(`[tg] ${msg}\n`); } catch {} }
    : undefined);

The same logger is threaded into wireInboundTransport, createAuthorizedDispatchHandler, and createPairingHandler.

Common log lines

[tg] [telegram inbound] kind=message chatId=12345 fromId=67890 allow=true
[tg] [telegram inbound] kind=callback_query chatId=12345 fromId=67890 allow=false:not-allowlisted
[tg] [telegram inbound] /start outcome=paired reason=-
[tg] [telegram inbound] handleCallbackQuery threw: …
[tg] [telegram dispatch] /crew coordinator.sendPrompt rejected: …
[tg] [telegram coordinator-forward] notify failed: …

Adding a new log point

Just call deps.logger?.('[your-module] message') in your code path. The logger is already plumbed through every constructor in telegramSetup.ts. Avoid console.log — it interferes with the Ink alternate-screen buffer; the in-band logger writes raw to stderr which Ink ignores.


10. Adding a new slash command for Telegram

Command handlers live in src/commands/. Registration into commandMatrix (src/bot/commandMatrix.ts) controls per-channel availability:

  1. Implement the command in src/commands/<myCommand>.ts and register it through the existing registry.ts. The handler signature ((args, ctx) => CommandResult) is shared across Ink and Telegram — you do not need a Telegram-specific code path.
  2. Add a row to commandMatrix with a category. informational, interactive, destructive, sec-gated → routable to Telegram. ui-only → silently filtered out by isCommandSupported; the Telegram dispatcher politely rejects with the matrix reason.
  3. askUser-bearing commands need no special handling. Because PR #244 detached dispatch from the poll loop, a command that parks on coordinator.askUser for minutes is fine; the poller continues servicing other updates in parallel.
  4. Output flows through notify. Return { output: '...' } from your handler. The Telegram dispatcher calls deps.notify(result.output) which goes through the full egress pipeline (PII → MD2 → chunk → queue). Don't bypass this by calling transport.sendMessage directly.
  5. Coordinator queries — set shouldQuery: true and prompt: '…' on the CommandResult and the dispatcher fires coordinator.sendPrompt fire-and-forget. The coordinator's response reaches the user via the subscribed onMessage handler from §6.

11. Adding a new Channel implementation

Use TelegramChannel as the reference. A skeleton SlackChannel:

ts
import type { Channel, AskUserRequest, AskUserResult, ... } from './types.js';

export class SlackChannel implements Channel {
  readonly name = 'slack' as const;
  readonly capabilities = {
    supportsFreeform: true,
    supportsChoices: true,
    supportsSearch: false,
  };

  private attached = false;

  async attach(): Promise<void> {
    if (this.attached) return;
    // open RTM / Web API client
    this.attached = true;
  }

  async dispose(): Promise<void> {
    if (!this.attached) return;
    this.attached = false;
    // cancel every pending ask with { cancelled: true, reason: 'channel-disposed' }
  }

  isAttached(): boolean { return this.attached; }

  async askUser(req: AskUserRequest): Promise<AskUserResult> {
    // 1. render a Block Kit message with the choices / freeform prompt
    // 2. register a pending ask keyed by nonce
    // 3. wire req.signal.addEventListener('abort', () => {
    //      dismissBlockKitMessage(); resolve({ cancelled: true, reason: 'user-cancel' });
    //    }, { once: true });
    // 4. await user response → resolve({ answer })
  }

  async handlePermissionRequest(req) { /* ... */ }
  async pushUpdate(snapshot) { /* ... */ }
  async notify(level, message, options) { /* ... */ }
  declaresSupport(name: string): boolean {
    // return false for ui-only commands
  }
}

Contracts to honour (don't skip)

  • Abort signal. Mirror the InkChannel pattern from src/channels/InkChannel.tsx:118-135.
  • Idempotent dispose. Tests in src/channels/Channel.contract.test.ts enforce this for any channel that opts into the contract test.
  • No sync throws. Return rejected Promises instead.
  • Capability gates honest. supportsFreeform / supportsChoices / supportsSearch are consulted by supportsKind in ChannelHub — lying here causes the hub to route asks to a channel that can't render them.

Register with ChannelHub

ts
const slack = new SlackChannel(deps);
await channelHub.register(slack);  // calls attach()

Add a stop hook to the CLI shutdown pipeline mirroring setupTelegramRuntime's stop() so the channel disposes before session save runs.

Tests to add

Mirror the parity / contract suites:

  • A MyChannel.test.ts covering happy paths.
  • A MyChannel.permission.test.ts (model TelegramChannel.permission.test.ts).
  • A MyChannel.ask.choice.test.ts and .ask.freeform.test.ts.
  • An entry under src/channels/Channel.contract.test.ts (the shared contract assertions).
  • If your channel adds command routing, add a parity entry to src/providers/permissions-parity.test.ts.

12. Testing the Telegram feature

The repo uses Vitest (NOT Jest). Quick reference:

bash
npm test                                  # full suite
npx vitest run src/bot                    # all bot/ unit tests
npx vitest run src/channels/Telegram     # all TelegramChannel unit tests
npx vitest run src/cli/telegramInbound   # the dispatch tests
npx vitest run tests/e2e/scenarios/telegram-bot-pair.e2e.test.ts
npx vitest run tests/e2e/scenarios/telegram-channel-startup.e2e.test.ts

Unit test locations

  • src/bot/*.test.ts — transport, chunker, egress, interactions, pairing, outbound queue/ledger, command matrix, gate, etc.
  • src/channels/TelegramChannel*.test.ts — channel-level behaviour (askUser, permission, attach, implicit yolo, rememberFor, support).
  • src/channels/ChannelHub.test.ts — fan-out race + abort + timeouts.
  • src/channels/Channel.contract.test.ts — generic contract shared across channels.
  • src/cli/telegramSetup.test.ts — composition root wiring.
  • src/cli/telegramInboundDispatch.test.ts — gate + dispatch + authorized-handler detach behaviour.

E2E

  • tests/e2e/scenarios/telegram-channel-startup.e2e.test.ts
  • tests/e2e/scenarios/telegram-bot-pair.e2e.test.ts

Mock bot transport pattern

telegramSetup.ts accepts a transportFactory so tests can inject a fake BotTransport. The standard pattern is to build a fake whose onUpdate captures the handler so the test can push synthetic BotInboundUpdates and whose sendMessage records calls for assertion. See telegramSetup.test.ts for the canonical example.


13. Common bug patterns and fixes

BugPRLesson
Entry-point stub never replaced — --telegram exited with the M2.1 placeholder while the channel built in M2.3+ never ran#235Every milestone needs an entry-point integration test that exercises the actual binary, not the unit-tested module in isolation.
Bot received but never responded — coordinator output went to Ink only because the channel never subscribed to coordinator.addEventHandler#238Wiring outbound is not symmetric to wiring inbound; both directions need explicit integration tests.
MD2 fallback HTTP 400 loop — fallback re-sent with the failing parseMode: 'MarkdownV2' and Telegram returned the same 400#240Fallback paths need their own flag (noParseMode), not reuse of the failing flag.
Choices rendered as numbered text instead of inline buttons because replyMarkup was not plumbed through#242BotSendMessageOptions is a structural contract — typed surfaces must be wired end-to-end.
Callback queries from a second paired phone were rejected as wrong-auth; debug visibility was opt-in only after wiring a logger#243Multi-pairing demands relaxed auth at the registry level; AGENTS_FLEET_TG_DEBUG belongs in the default code path.
Long-poll stalled during interactive /crew askUser; Telegram delivered a queued burst on the next getUpdates#244I/O dispatch must be detached from async handlers; the poller awaits handler completion serially.
/effort and /crew crashed React because askUser was passed an object as the label#245as unknown casts hide contract violations until runtime — prefer narrow typed adapters at boundaries.
Ink dialog stayed open after Telegram answered an askUser; next CLI keystroke went to the orphaned dialog#246Channel implementations MUST honour req.signal; the fan-out abort contract is load-bearing.

14. Attachments (inbound media + outbound send_attachment)

The attachment surface adds a second data plane on top of the text-only inbound/outbound flows above. Both directions reuse the existing auth gate (gateUpdate) and the existing detached-dispatch posture (PR #244); the new components are AttachmentStore, the handleInboundAttachments pipeline, the send_attachment coordinator tool, and the AttachmentSender interface that decouples the tool from any specific transport.

14.1 AttachmentStore contract

src/bot/attachmentStore.ts is the sole persistence point for inbound files. The constructor takes a single absolute rootDir; production passes <home>/.fleet/attachments from telegramSetup.ts:368. Per-session sub-directories are created on demand at mode 0o700; files inside are written at mode 0o600.

Key guarantees:

  • Filename sanitization (sanitizeAttachmentName): collapses directory separators, strips leading dots, replaces anything outside [a-zA-Z0-9._-] with _, trims trailing ./space, falls back to attachment.bin if nothing alphanumeric remains.
  • Collision handling: appends -1, -2, …, up to -999 to the stem; throws if exhausted. The check considers both the final path and a same-name .partial sidecar so concurrent saves don't race onto the same target.
  • Atomic writes: bytes go to <dest>.partial via createWriteStream({ flags: 'wx' }) and are renamed into place only on successful pipeline completion. The .partial sidecar is removed on error.
  • Symlink-safe load: AttachmentStore.load(absolutePath) rejects paths outside rootDir, refuses to follow symlinks (lstatisSymbolicLink), and requires a regular file.
  • list(sessionId) skips .partial sidecars so callers see only fully-stored attachments.

14.2 Inbound dispatch flow

Inbound attachments flow

(Source: diagrams/telegram/inbound-attachments.mmd)

Two important details:

  1. Caption echo is synchronous, attachments are detached. The same pattern as PR #244: an interactive coordinator handler must not block pollOnce, so handleInboundAttachments runs detached (void … .catch(log)). The synchronous caption echo preserves transcript ordering relative to the user's typing.
  2. Single combined prompt. Caption + every successfully stored attachment turn into ONE coordinator prompt (buildAttachmentPrompt), so the coordinator sees a single turn rather than a noisy interleaving. When zero attachments succeed the prompt is skipped entirely; the caption remains in the local transcript via the inline echo.

14.3 Outbound flow

Outbound attachments flow

(Source: diagrams/telegram/outbound-attachments.mmd)

The tool returns structured results — it never throws on per-chat failures. The coordinator sees { ok, kind, path, sentCount, errors? } and can recover (retry with kind='document', drop the caption, etc.).

The tool sets skipPermission: true. This matches the posture for other coordinator-initiated outbound surfaces (notify, sendMessage) — the file path is data the coordinator already produced, not a fresh escalation requiring a permission prompt.

14.4 File-naming map

chooseAttachmentFileName(attachment) in telegramInboundDispatch.ts:615 prefers Telegram's fileName when present. When absent (photos, voice notes, stripped documents) it falls back to <fileUniqueId><ext>, where <ext> comes from MIME_EXTENSION_MAP (telegramInboundDispatch.ts:576-600):

MIMEExt
image/jpeg, image/jpg.jpg
image/png.png
image/gif.gif
image/webp.webp
image/heic.heic
image/svg+xml.svg
video/mp4.mp4
video/quicktime.mov
video/webm.webm
audio/mpeg.mp3
audio/mp4, audio/x-m4a.m4a
audio/ogg.ogg
audio/opus.opus
audio/wav.wav
application/pdf.pdf
application/zip.zip
application/json.json
application/octet-stream, unknown, absent.bin
text/plain.txt
text/markdown.md
text/csv.csv

Once the basename is chosen it always passes through sanitizeAttachmentName (see §14.1). Collisions add a -N suffix before the extension.

14.5 Failure boundaries

FailureWhere caughtEffect
downloadFile throws (network, HTTP non-2xx, empty body)per-attachment try in handleInboundAttachmentsAttachment skipped, temp file removed, logged, loop continues.
saveFromStream throws (disk full, permission, collision exhaustion)per-attachment trySame — logged + skipped.
Every attachment failedpost-loop guardNo coordinator prompt sent; caption already echoed to transcript.
coordinator.sendPrompt rejectsfire-and-forget .catch(log)Logged via deps.logger; no further retry (matches freeform path).
send_attachment called with no attachmentSender wiredtool handlerReturns { ok: false, error: 'No attachment channel is attached…' }.
send_attachment called with missing / unreadable pathfs.accessSync / fs.statSync guardReturns { ok: false, error: 'Cannot read file …' }.
send_attachment per-chat send failsAttachmentSender implementation must collectReturned in errors[] alongside sentCount of successes.

15. Glossary

  • Channel — the abstraction in src/channels/Channel.ts; one rendering surface for asks/permissions/notifications/dashboards.
  • ChannelHubsrc/channels/ChannelHub.ts; owns attach/dispose bookkeeping and the askUser/permission fan-out race.
  • askUserChannel.askUser(req)AskUserResult. Kinds: freeform, choice, searchable-choice.
  • askPermission — alias for Channel.handlePermissionRequest(req)PermissionDecision. Hub fails-closed (deny) after 2 min.
  • askChoice / multi-tap — a choice prompt that requires the same button tapped N times to confirm (SEC-gated destructive ops). State in src/bot/multiTapConfirm.ts.
  • InteractionRegistrysrc/bot/interactions.ts; nonce ledger that correlates an inbound callback to a pending ask.
  • nonce — 4-byte hex string embedded in callback_data as <nonce>:<idx>. Generated with crypto.randomBytes — never Math.random.
  • pairing(chatId, fromId) pair authorized to drive the fleet, persisted in ~/.fleet/telegram.json.
  • TOFU — Trust-On-First-Use. The first /start <token> whose token matches the pending pairing claims the chat+user pair.
  • MD2 — short for Telegram MarkdownV2. Reserved characters listed in src/bot/telegramMd.ts.
  • parse_mode — Telegram Bot API field; 'MarkdownV2' enables MD2 rendering. Omitting it ships text as plaintext (the noParseMode fallback).
  • reply_markup — Telegram Bot API field carrying the inline keyboard. Surfaces as BotSendMessageOptions.replyMarkup.
  • allowed_updates — the narrow list (message, edited_message, callback_query) passed to getUpdates to avoid burning quota on update kinds we don't process.
  • callback_query — the Telegram update kind generated when a user taps an inline-keyboard button.
  • AttachmentStoresrc/bot/attachmentStore.ts; persistent inbound file storage rooted at <home>/.fleet/attachments/. Owns filename sanitization, collision suffixing, atomic .partial → final rename, symlink-rejecting load.
  • send_attachment — coordinator tool (src/coordinator/tools/sendAttachment.ts) that pushes a local file to every paired chat via the transport-agnostic AttachmentSender interface. kind='auto' resolves to photo/audio/video/document by extension.
  • AttachmentSender — single-method structural interface implemented by transports that can deliver outbound files. TelegramChannel is the production implementation; the contract intentionally returns a structured per-chat result rather than throwing.
  • artifactCandidates — post-completion surface emitted by workers listing files they produced during a task. The coordinator inspects the list and decides whether to call send_attachment to forward any of them; workers do not auto-deliver.