Telegram Remote Steering — Developer Architecture
Audience: developers extending, debugging, or maintaining the
--telegramchannel. For the end-user pair/use/troubleshoot guide seetelegram-remote-steering.md. For the attachment feature (inbound media + outboundsend_attachment) seetelegram-attachments.md. For the security model (TOFU, token = full control, multi-tap SEC, file handling) seetelegram-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/— theChannelcontract, theChannelHubfan-out shell, theInkChannelandTelegramChannelimplementations.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
(Source: diagrams/telegram/inbound-callback.mmd)
Inbound: user types text → coordinator
(Source: diagrams/telegram/inbound-text.mmd)
Outbound: coordinator → Telegram
(Source: diagrams/telegram/outbound.mmd)
3. Key components and where they live
| Component | Path | Purpose |
|---|---|---|
Channel interface | src/channels/Channel.ts | Contract every UI surface implements (askUser, permission, notify, pushUpdate, dispose). |
ChannelHub | src/channels/ChannelHub.ts | Owns attach/dispose bookkeeping and the parallel askUser / handlePermissionRequest race; aborts losers via shared AbortController. |
InkChannel | src/channels/InkChannel.tsx | Ink/React rendering of dialogs, dashboards, notifications. Honours req.signal to dismiss dialogs when fan-out aborts (PR #246). |
TelegramChannel | src/channels/TelegramChannel.ts | Bot-side rendering: prompt → inline keyboard, freeform → reply window, multi-tap → confirm button. |
BotTransport | src/bot/transport.ts | SOLE grammy importer. Owns pollOnce, adaptUpdate, onUpdate, sendMessage, answerCallbackQuery, dedupe LRU, exponential backoff. |
InteractionRegistry | src/bot/interactions.ts | 4-byte hex nonce ledger; resolves handleCallback(nonce, chatId, fromId, value, authorizedPairings) to 'ok' | 'unknown-nonce' | 'wrong-auth' | 'expired' | 'already-answered'. |
MultiTapConfirmRegistry | src/bot/multiTapConfirm.ts | Three-tap SEC confirmation state. |
OutboundQueue + OutboundLedger | src/bot/outboundQueue.ts, src/bot/outboundLedger.ts | Per-chat serialized send queue with msgKey dedupe + retry ledger. |
| Egress pipeline | src/bot/egressPipeline.ts | PII redact → MD2 escape → chunk → enqueue. Owns the noParseMode fallback path. |
PiiRedactor | src/intel/PiiRedactor.ts | Stateless secret/PII scrubber reused for both intel and Telegram egress. |
toTelegramMd | src/bot/telegramMd.ts | MarkdownV2 escape for the 18 reserved characters. |
chunkForTelegram | src/bot/chunker.ts | Safe-boundary chunk splitter (paragraph → sentence → 4096-byte fallback). |
| Allowlist gate | src/bot/inbound.ts | gateUpdate(meta, config) — central auth decision used by every inbound path. |
| Pairing | src/bot/pairing.ts | /start <token> TOFU pairing; persists to ~/.fleet/telegram.json. |
setupTelegramRuntime | src/cli/telegramSetup.ts | Composition root — wires transport + queue + registries + channel, returns { channel, transport, wireInbound, stop }. |
wireInboundTransport | src/cli/telegramInboundDispatch.ts | Registers transport.onUpdate; gates and branches by update.kind. |
createAuthorizedDispatchHandler | src/cli/telegramInboundDispatch.ts | Detaches dispatchTelegramText from the poll loop after a synchronous notifyExternalInput (PR #244). |
dispatchTelegramText | src/cli/telegramInboundDispatch.ts | Routes text to slash-command handler or coordinator.sendPrompt. |
CoordinatorEngine.notifyExternalInput | src/coordinator/CoordinatorEngine.ts | Synchronous fan-out to event handlers (e.g. TranscriptBridge) so the local Ink transcript shows [via Telegram] … immediately. |
TranscriptBridge | src/repl/TranscriptBridge.ts | Subscribes onExternalInput and appends a via Telegram line to the Ink transcript. |
AttachmentStore | src/bot/attachmentStore.ts | Persistent inbound-attachment storage rooted at <home>/.fleet/attachments/. Owns filename sanitization, collision suffixing, atomic .partial → final rename, symlink-rejecting load. |
handleInboundAttachments | src/cli/telegramInboundDispatch.ts | Detached per-update pipeline: transport.downloadFile → AttachmentStore.saveFromStream → notifyExternalInput echo → coordinator.sendPrompt(caption + bullets). |
send_attachment tool + AttachmentSender | src/coordinator/tools/sendAttachment.ts | Coordinator tool that pushes a local file to every paired chat via a transport-agnostic AttachmentSender contract. kind=auto resolves by extension. |
| Bot index/barrel | src/bot/index.ts | Re-exports the public surface; never re-exports grammy types. |
4. The Channel interface contract
Every channel implements src/channels/Channel.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
askUserMUST honourreq.signal.ChannelHubraces every supporting channel under a singleAbortControllerand callsabort.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 inInkChannel.tsxatreq.signal.addEventListener('abort', onAbort, { once: true }).dispose()must be idempotent and settle every in-flight ask as{ cancelled: true, reason: 'channel-disposed' }. The hub calls dispose on shutdown and onunregister.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.declaresSupport(commandName)is pure. Used byisCommandSupportedto filter UI-only commands (/scroll,/copy, …) out of Telegram dispatch.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:
- 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. - 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'anddecision.allow === true→ directly callchannel.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)
bot.api.getUpdatesreturns oneUpdate.adaptUpdateprojects it to aBotInboundUpdate(ornullfor kinds we don't handle).transport.onUpdate(handler)invokes the handler registered bywireInboundTransport.- Handler builds an
InboundCtxShim, readstelegram.jsonfresh, computesmetaviaextractUpdateMeta, thendecision = gateUpdate(meta, config). - Branch:
kind === 'callback_query'→channel.handleCallbackQuery(…)→interactions.handleCallback(nonce, chatId, fromId, value, authorizedPairings).- else →
channel.routeUpdate(decision, ctxShim).
- For authorized messages,
routeUpdateinvokes the channel'sonAuthorizedUpdatehook, which is bound tocreateAuthorizedDispatchHandler:coordinator.notifyExternalInput('telegram', trimmed)(sync).void dispatchTelegramText(text, deps)(detached).
dispatchTelegramTextclassifies the input and either:- runs a local command and
notifys the result, or - calls
coordinator.sendPrompt(text)and lets the coordinator'sonMessageflow back through the outbound path.
- runs a local command and
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):
- PII redaction —
PiiRedactor.redact(text). - MarkdownV2 escape —
toTelegramMd(redacted). If this throws (corrupt input or a redactor regression), the pipeline collapses toFORMAT_FAILURE_PLACEHOLDERrather than leaking unescaped content. - Chunking —
chunkForTelegram(escaped)splits on paragraph/sentence boundaries before the 4096-byte fallback. - Enqueue — each chunk lands in
OutboundQueuewith a sequentialmsgKeyso the ledger can dedupe and retry without re-ordering. - Send — the queue calls the
sendTextadapter fromtelegramSetup.ts:204, which forwards{ parseMode: 'MarkdownV2' }toBotTransport.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'sreq.signalfires; theiraskUserimplementation 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:freeform10 min,choice/searchable-choice5 min.AskUserRequest.timeoutoverrides.
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:
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:
- Implement the command in
src/commands/<myCommand>.tsand register it through the existingregistry.ts. The handler signature ((args, ctx) => CommandResult) is shared across Ink and Telegram — you do not need a Telegram-specific code path. - Add a row to
commandMatrixwith acategory.informational,interactive,destructive,sec-gated→ routable to Telegram.ui-only→ silently filtered out byisCommandSupported; the Telegram dispatcher politely rejects with the matrixreason. - askUser-bearing commands need no special handling. Because PR #244 detached dispatch from the poll loop, a command that parks on
coordinator.askUserfor minutes is fine; the poller continues servicing other updates in parallel. - Output flows through
notify. Return{ output: '...' }from your handler. The Telegram dispatcher callsdeps.notify(result.output)which goes through the full egress pipeline (PII → MD2 → chunk → queue). Don't bypass this by callingtransport.sendMessagedirectly. - Coordinator queries — set
shouldQuery: trueandprompt: '…'on theCommandResultand the dispatcher firescoordinator.sendPromptfire-and-forget. The coordinator's response reaches the user via the subscribedonMessagehandler from §6.
11. Adding a new Channel implementation
Use TelegramChannel as the reference. A skeleton SlackChannel:
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 insrc/channels/Channel.contract.test.tsenforce this for any channel that opts into the contract test. - No sync throws. Return rejected Promises instead.
- Capability gates honest.
supportsFreeform/supportsChoices/supportsSearchare consulted bysupportsKindinChannelHub— lying here causes the hub to route asks to a channel that can't render them.
Register with ChannelHub
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.tscovering happy paths. - A
MyChannel.permission.test.ts(modelTelegramChannel.permission.test.ts). - A
MyChannel.ask.choice.test.tsand.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:
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.tsUnit 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.tstests/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
| Bug | PR | Lesson |
|---|---|---|
Entry-point stub never replaced — --telegram exited with the M2.1 placeholder while the channel built in M2.3+ never ran | #235 | Every 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 | #238 | Wiring 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 | #240 | Fallback 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 | #242 | BotSendMessageOptions 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 | #243 | Multi-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 | #244 | I/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 | #245 | as 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 | #246 | Channel 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 toattachment.binif nothing alphanumeric remains. - Collision handling: appends
-1,-2, …, up to-999to the stem; throws if exhausted. The check considers both the final path and a same-name.partialsidecar so concurrent saves don't race onto the same target. - Atomic writes: bytes go to
<dest>.partialviacreateWriteStream({ flags: 'wx' })and arerenamed into place only on successful pipeline completion. The.partialsidecar is removed on error. - Symlink-safe load:
AttachmentStore.load(absolutePath)rejects paths outsiderootDir, refuses to follow symlinks (lstat→isSymbolicLink), and requires a regular file. list(sessionId)skips.partialsidecars so callers see only fully-stored attachments.
14.2 Inbound dispatch flow
(Source: diagrams/telegram/inbound-attachments.mmd)
Two important details:
- Caption echo is synchronous, attachments are detached. The same pattern as PR #244: an interactive coordinator handler must not block
pollOnce, sohandleInboundAttachmentsruns detached (void … .catch(log)). The synchronous caption echo preserves transcript ordering relative to the user's typing. - 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
(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):
| MIME | Ext |
|---|---|
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
| Failure | Where caught | Effect |
|---|---|---|
downloadFile throws (network, HTTP non-2xx, empty body) | per-attachment try in handleInboundAttachments | Attachment skipped, temp file removed, logged, loop continues. |
saveFromStream throws (disk full, permission, collision exhaustion) | per-attachment try | Same — logged + skipped. |
| Every attachment failed | post-loop guard | No coordinator prompt sent; caption already echoed to transcript. |
coordinator.sendPrompt rejects | fire-and-forget .catch(log) | Logged via deps.logger; no further retry (matches freeform path). |
send_attachment called with no attachmentSender wired | tool handler | Returns { ok: false, error: 'No attachment channel is attached…' }. |
send_attachment called with missing / unreadable path | fs.accessSync / fs.statSync guard | Returns { ok: false, error: 'Cannot read file …' }. |
send_attachment per-chat send fails | AttachmentSender implementation must collect | Returned in errors[] alongside sentCount of successes. |
15. Glossary
- Channel — the abstraction in
src/channels/Channel.ts; one rendering surface for asks/permissions/notifications/dashboards. - ChannelHub —
src/channels/ChannelHub.ts; owns attach/dispose bookkeeping and the askUser/permission fan-out race. - askUser —
Channel.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. - InteractionRegistry —
src/bot/interactions.ts; nonce ledger that correlates an inbound callback to a pending ask. - nonce — 4-byte hex string embedded in
callback_dataas<nonce>:<idx>. Generated withcrypto.randomBytes— neverMath.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 (thenoParseModefallback). - 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 togetUpdatesto 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.
- AttachmentStore —
src/bot/attachmentStore.ts; persistent inbound file storage rooted at<home>/.fleet/attachments/. Owns filename sanitization, collision suffixing, atomic.partial → finalrename, symlink-rejectingload. - send_attachment — coordinator tool (
src/coordinator/tools/sendAttachment.ts) that pushes a local file to every paired chat via the transport-agnosticAttachmentSenderinterface.kind='auto'resolves tophoto/audio/video/documentby extension. - AttachmentSender — single-method structural interface implemented by transports that can deliver outbound files.
TelegramChannelis 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_attachmentto forward any of them; workers do not auto-deliver.