Orion’s primary surface is the desktop app. Channels are alternate input/output routes — Telegram and Discord today. A user can chat with their Orion agent from their phone via these bots; replies arrive back through the same channel.

What “channels” means here

The sidecar/bot/ module wires two inbound message platforms:
PlatformLibraryStatus
TelegramgrammYImplemented (telegram-bot.mjs, 503 lines)
Discorddiscord.jsImplemented (discord-bot.mjs, 519 lines)
There is no plugin architecture for adding new channels — bot-service.mjs:329-337 _createBot() is a hard-coded switch over 'telegram' and 'discord'. Slack, SMS, email, etc. would each require a dedicated implementation.

End-to-end message lifecycle (Telegram)

Concrete file references:
  • Polling setup + stale-message guard: telegram-bot.mjs:64-123, :68-70 (60-second stale drop)
  • Pairing check: telegram-bot.mjs:320pairing.mjs:117 isAllowed()
  • Session lookup: telegram-bot.mjs:401session-manager.mjs:42-61
  • Engine spawn: bot-service.mjs:171-299 executeQuery()
  • Stream chunking: telegram-bot.mjs:373-393 (onTextChunk, 1500 ms throttle)
  • Final-message split: telegram-bot.mjs:420-436 + message-splitter.mjs:18-67
  • Media detection regex: telegram-bot.mjs:28-39 (matches absolute paths ending in image / video / pdf extensions, filters to existsSync())
  • Media delivery: telegram-bot.mjs:466-484 (_sendOutboundMedia)
Discord differences (otherwise structurally identical):
AspectTelegramDiscord
Message limit4096 chars2000 chars
Typing indicator interval4000 ms9000 ms
Edit throttle1500 ms2000 ms
Free-form messagesAny chatDM-only (message.guild !== null → ignore, discord-bot.mjs:255)
Commands/start, /pair, /new, /sessions, /help (text)Same, as slash commands registered globally (discord-bot.mjs:114-139)
Outbound mediaPer-file replyWithPhoto/Video/DocumentBatched channel.send({ files: [...] })
Parse modeTries Markdown, falls back to plainPlain text only

Pairing flow

Users link their messaging account to their Orion identity through a short-lived numeric code. Mechanics:
  • Code generation: pairing.mjs:62-76. 6 chars from ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (no 0/O/1/I/l ambiguity) via randomBytes(6) with modulo sampling. 5-minute TTL.
  • User confirms in Settings → Messaging → Pair New Device (MessagingSection.tsx:111, PairingCodeEntry.tsx).
  • confirmPairing in botStore.ts:188botConfirmPairing(platform, code) → IPC bot_confirm_pairing (bot.rs:114-126) → stdin to bot-service.
  • PairingManager.confirmCode() (pairing.mjs:83-109) moves the entry from _pending to _allowed and calls _saveToDisk(){vaultRoot}/.orion/bot-pairings.json.
Pairings survive sidecar restart as of the 2026-04-06 fix session. A prior bug had pairings in-memory only, lost on every restart. PairingManager now calls _loadFromDisk() in its constructor (pairing.mjs:44-52) and writes to disk on every confirm and revoke. Pending (unconfirmed) codes remain in-memory and expire — correct behavior.
Platform isolation: keys are namespaced telegram:{platformUserId} and discord:{platformUserId} in the same JSON file. Discord and Telegram pairings cannot collide.

Engine routing on bots — Claude SDK only

This is the most important dual-engine fact on this page. bot-service.mjs:171-299 executeQuery() unconditionally spawns node sdk-runner.mjs. There is no call to routeModel(), no Pi engine path, no provider/model dispatch logic. The hard-coded model:
model: DEFAULT_MODEL  // 'claude-sonnet-4-6' from config/constants.mjs:34
permissionMode: 'bypassPermissions'
Implication: bot sessions are always claude-sonnet-4-6. A user whose primary chat preference is Gemini Flash or GPT-4 still gets Sonnet 4.6 over Telegram. No UI exists to change this. No per-message override. Compare:
SurfaceEngine selection
Desktop chatModel picker in chat bar, per-conversation
Cron jobspayload.model per job; preference keys for ambient (heartbeat_model, enrichment_model, etc.)
Bot sessionsHardcoded claude-sonnet-4-6, no user control
Billing follows: bot sessions are billed via the proxy-* prefix (Anthropic through the Cloudflare Worker), never the cron-pi-* Pi path. Observability: when ORION_BOT_SESSION=1 (bot-service.mjs:202), the SDK query gets Langfuse tags bot:true and bot_platform:{telegram|discord}, plus botPlatform / botChatId metadata (handler.mjs:900-907, :951-954). Bot traces are separable from desktop traces.

Permissions and sandbox

Bot sessions run with three hardcoded settings:
permissionMode: 'bypassPermissions'
sandbox: {
  enabled: true,
  autoAllowBashIfSandboxed: true,
  allowUnsandboxedCommands: true,
}
(from bot-service.mjs:188-193) This routes through createBypassCanUseTool (handler.mjs:484-497) which auto-approves every tool call except AskUserQuestion. Specifically:
if (toolName === 'AskUserQuestion' || toolName === 'mcp__orion__ask_user_question') {
  return canUseToolFn(toolName, input, options); // routes to interactive handler
}
Bot agents must never call AskUserQuestion. There is no interactive surface on the bot channel. If the agent calls it, the query stalls 120 seconds waiting for a permissionRequest event that no UI handles, then times out. This is an open gap — the BOT_SYSTEM_PROMPT (bot-service.mjs:142-158) does not explicitly forbid it.
File writes: Bash and file ops are sandbox-restricted to cwd, additionalDirectories, and /tmp. Inbound photos and documents are written to {ORION_VAULT_ROOT}/Resources/media/images/tg-{ts}.{ext} (telegram-bot.mjs:267). Generated media uses resolveMediaOutputDir() — standard vault path.

System prompt for bot agents

Distinct from the desktop system prompt. Defined inline at bot-service.mjs:142-158 as BOT_SYSTEM_PROMPT, passed via options.systemPrompt per query. Key sections:
You are Orion, responding via a messaging bot (Telegram/Discord).
Keep responses concise and conversational — these are chat messages, not documents.
...
CRITICAL — How media works in messaging mode:
You CAN generate images and videos using the orion media CLI tool.
After generation, you MUST include the FULL ABSOLUTE file path in your response...
DO NOT say "I can't send images" or "check the canvas" — you are NOT on desktop,
there is no canvas.
Desktop-only capabilities (canvas panel, file editing, UI widgets) are NOT
available via messaging.
The “absolute file path in the response” instruction is what powers extractMediaPaths — the agent must literally write the path, and the bot post-processor finds it and uploads the file. A prior version of this prompt said media was “desktop-only,” which caused generated images to never deliver via Telegram. That was fixed; the current prompt explicitly tells the agent it CAN generate media.

Persistence

WhatWhereSurvives restart?
Bot tokens{vaultRoot}/.orion/bot-tokens.json (token-store.mjs:38)Yes — bot-service.mjs:453-483 re-reads on startup
Pairings{vaultRoot}/.orion/bot-pairings.json (pairing.mjs:165-167)Yes (since the 2026-04-06 fix)
Bot session map (chatId → conversationId, sdkSessionId)In-memory Map in session-manager.mjs:19No — see GAP-C2 below
bot_sessions Drizzle table for UI displaySidecar DBPersisted, but write path unclear (see GAP-C3)
Advisory lock{vaultRoot}/.orion/bot-service.lockReleased on shutdown, 5-minute liveness probe (bot-service.mjs:60-118)
Security note on tokens. Bot tokens are stored in plaintext in the vault. They are NOT Cloudflare Worker secrets — those are for API keys the server needs; bot tokens are user-owned and must live on-device. The Rust bot_get_config command (bot.rs:33-81) returns only { hasToken: bool, enabled: bool } to the frontend — the actual token never crosses the Tauri IPC boundary. stdout_reader in bot_service.rs:593-596 logs only event type, not payload.

UI surface

ComponentFilePurpose
Settings → Messagingsrc/components/settings/MessagingSection.tsxTop-level section
Platform cardmessaging/PlatformCard.tsxPer-platform: status badge, toggle, token input
Pairing managermessaging/PairingManager.tsxActive pairings list + revoke
Pairing code entrymessaging/PairingCodeEntry.tsx6-char code input
Status states (PlatformCard.tsx:18-24):
StatusDisplayToken color
connected”Connected”orion-success
connecting”Connecting…”orion-gold
disconnected”Not configured”muted
error”Error”orion-error
conflict”This bot is running on another device”orion-warning
The 'conflict' state is rendered but may be unreachable — the advisory-lock check in bot-service.mjs:60-108 calls process.exit(0) on conflict rather than emitting a status event. See GAP-C5.

Honest gaps

The bot path has the largest concentration of known issues in the codebase. Five fixed, two open, and a handful of structural gaps.

Fixed bugs

#BugRoot causeFix
C-FIX-1Token destroyed by enable/disable togglesetEnabled called botConfigurePlatform(platform, null, enabled)null !== undefined so the token slot was overwritten with empty stringbot-service.mjs:352-358 preserves stored token unless explicit non-empty value provided
C-FIX-2window=None event drops (status stuck on “Connecting…”)bot_service.rs::stdout_reader skipped win.emit() when window was None with no logbot_service.rs:619-622 now logs the dropped event type (diagnostic only — event still lost; see GAP-C4)
C-FIX-3Invalid status strings crashing PlatformCardBot emitted 'configured' / 'disabled' — not in the PlatformStatus union → indexed undefinedAll emits now use 'disconnected' (bot-service.mjs:377-378, :383-385)
C-FIX-4Silent media-send failuresreplyWithPhoto errors went to stderr only — user saw nothingtelegram-bot.mjs:479-481 now replies with ⚠️ Failed to send {filename}: {error}
C-FIX-5System prompt said “media is desktop-only”Prompt told the agent images couldn’t be sent → agent refused to generatePrompt rewritten to instruct full absolute path in response (bot-service.mjs:145-156)

Open gaps

#GapSeverityWhere
C-OPEN-1AskUserQuestion stall — agent calling it hangs 120s then times outMediumNo UI handles permissionRequest for bot context; BOT_SYSTEM_PROMPT doesn’t forbid the tool
C-OPEN-2Session continuity lost on bot service restartMediumSessionManager Map is in-memory only (session-manager.mjs:19). conversationId is deterministic so it regenerates, but sdkSessionId (for SDK conversation resumption from prompts/projects/) starts null
C-OPEN-3Max-restart exhaustion silently deadMediumAfter 3 crashes the watchdog gives up and emits botServiceFailedbotStore.ts:89-110 only handles botStatus and pairings events. Bot card shows last known status forever
C-OPEN-4window=None race still drops eventsLowFix C-FIX-2 logs but doesn’t retry. If set_window() races with start(), real events lost
C-OPEN-5conflict status is likely dead UI codeLowAdvisory lock conflict path calls process.exit(0); no emit of { type: 'botStatus', status: 'conflict' } was found in bot-service.mjs

Structural gaps

#GapSeverityNote
C-GAP-1No Pi engine support for bots. All bot sessions are claude-sonnet-4-6. No model preference, no per-message override.MediumIntentional simplification; undocumented in UI
C-GAP-2No model selection UI for bots. Compare cron’s 5 preference keys.Lowbot-service.mjs:181 hardcoded
C-GAP-3bot_sessions Drizzle table write path unclear. session-manager.mjs:7 calls it the “UI-display source of truth” but no write was found in bot files. The sidebar/history for bot conversations may be empty.MediumVerify on next surface audit
C-GAP-4No Slack / SMS / email / other channels. _createBot() is a 2-platform hardcoded switch.AcceptedPlugin architecture would be a refactor
C-GAP-5No per-message Pi routing (e.g., @gemini what's the weather).LowNo prefix parsing exists
C-GAP-6No voice message support (message:voice) or stickers.LowTelegram handler only registers message:text, message:photo, message:document (telegram-bot.mjs:81-86)
C-GAP-7Queue stats (messageQueue.getStats()) not surfaced in UI.LowData is in botServiceStatus events; no React renderer consumes it
C-GAP-8No per-user context injection — BOT_SYSTEM_PROMPT is identical for all users. The agent has to actively call MCP tools to know the user’s name, vault, PARA state.MediumCould be addressed by userContext prepend
C-GAP-9maxBudgetUsd: 0.50 per query hardcoded. Long agentic tasks may hit the cap mid-flow. No UI to adjust.Lowbot-service.mjs:182
C-GAP-10Discord 8MB attachment limit unhandled — batch send fails entirely if any one file exceeds. No per-file retry or fallback.Mediumdiscord-bot.mjs:493-497

See also

  • Two engines — why bot’s “Claude SDK only” is a real constraint vs an abstract one
  • MCP, Permissions & Sandbox — the bypassPermissions mode and what it skips
  • Memory — bot conversations share the conversations + session_index tables and knowledge_search index with desktop
  • Trust / Safety — the bot path is the largest open-gap concentration in the codebase