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:
| Platform | Library | Status |
|---|
| Telegram | grammY | Implemented (telegram-bot.mjs, 503 lines) |
| Discord | discord.js | Implemented (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:320 → pairing.mjs:117 isAllowed()
- Session lookup:
telegram-bot.mjs:401 → session-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):
| Aspect | Telegram | Discord |
|---|
| Message limit | 4096 chars | 2000 chars |
| Typing indicator interval | 4000 ms | 9000 ms |
| Edit throttle | 1500 ms | 2000 ms |
| Free-form messages | Any chat | DM-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 media | Per-file replyWithPhoto/Video/Document | Batched channel.send({ files: [...] }) |
| Parse mode | Tries Markdown, falls back to plain | Plain 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:188 → botConfirmPairing(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:
| Surface | Engine selection |
|---|
| Desktop chat | Model picker in chat bar, per-conversation |
| Cron jobs | payload.model per job; preference keys for ambient (heartbeat_model, enrichment_model, etc.) |
| Bot sessions | Hardcoded 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
| What | Where | Survives 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:19 | No — see GAP-C2 below |
bot_sessions Drizzle table for UI display | Sidecar DB | Persisted, but write path unclear (see GAP-C3) |
| Advisory lock | {vaultRoot}/.orion/bot-service.lock | Released 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
| Component | File | Purpose |
|---|
| Settings → Messaging | src/components/settings/MessagingSection.tsx | Top-level section |
| Platform card | messaging/PlatformCard.tsx | Per-platform: status badge, toggle, token input |
| Pairing manager | messaging/PairingManager.tsx | Active pairings list + revoke |
| Pairing code entry | messaging/PairingCodeEntry.tsx | 6-char code input |
Status states (PlatformCard.tsx:18-24):
| Status | Display | Token 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
| # | Bug | Root cause | Fix |
|---|
| C-FIX-1 | Token destroyed by enable/disable toggle | setEnabled called botConfigurePlatform(platform, null, enabled) — null !== undefined so the token slot was overwritten with empty string | bot-service.mjs:352-358 preserves stored token unless explicit non-empty value provided |
| C-FIX-2 | window=None event drops (status stuck on “Connecting…”) | bot_service.rs::stdout_reader skipped win.emit() when window was None with no log | bot_service.rs:619-622 now logs the dropped event type (diagnostic only — event still lost; see GAP-C4) |
| C-FIX-3 | Invalid status strings crashing PlatformCard | Bot emitted 'configured' / 'disabled' — not in the PlatformStatus union → indexed undefined | All emits now use 'disconnected' (bot-service.mjs:377-378, :383-385) |
| C-FIX-4 | Silent media-send failures | replyWithPhoto errors went to stderr only — user saw nothing | telegram-bot.mjs:479-481 now replies with ⚠️ Failed to send {filename}: {error} |
| C-FIX-5 | System prompt said “media is desktop-only” | Prompt told the agent images couldn’t be sent → agent refused to generate | Prompt rewritten to instruct full absolute path in response (bot-service.mjs:145-156) |
Open gaps
| # | Gap | Severity | Where |
|---|
| C-OPEN-1 | AskUserQuestion stall — agent calling it hangs 120s then times out | Medium | No UI handles permissionRequest for bot context; BOT_SYSTEM_PROMPT doesn’t forbid the tool |
| C-OPEN-2 | Session continuity lost on bot service restart | Medium | SessionManager 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-3 | Max-restart exhaustion silently dead | Medium | After 3 crashes the watchdog gives up and emits botServiceFailed — botStore.ts:89-110 only handles botStatus and pairings events. Bot card shows last known status forever |
| C-OPEN-4 | window=None race still drops events | Low | Fix C-FIX-2 logs but doesn’t retry. If set_window() races with start(), real events lost |
| C-OPEN-5 | conflict status is likely dead UI code | Low | Advisory lock conflict path calls process.exit(0); no emit of { type: 'botStatus', status: 'conflict' } was found in bot-service.mjs |
Structural gaps
| # | Gap | Severity | Note |
|---|
| C-GAP-1 | No Pi engine support for bots. All bot sessions are claude-sonnet-4-6. No model preference, no per-message override. | Medium | Intentional simplification; undocumented in UI |
| C-GAP-2 | No model selection UI for bots. Compare cron’s 5 preference keys. | Low | bot-service.mjs:181 hardcoded |
| C-GAP-3 | bot_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. | Medium | Verify on next surface audit |
| C-GAP-4 | No Slack / SMS / email / other channels. _createBot() is a 2-platform hardcoded switch. | Accepted | Plugin architecture would be a refactor |
| C-GAP-5 | No per-message Pi routing (e.g., @gemini what's the weather). | Low | No prefix parsing exists |
| C-GAP-6 | No voice message support (message:voice) or stickers. | Low | Telegram handler only registers message:text, message:photo, message:document (telegram-bot.mjs:81-86) |
| C-GAP-7 | Queue stats (messageQueue.getStats()) not surfaced in UI. | Low | Data is in botServiceStatus events; no React renderer consumes it |
| C-GAP-8 | No 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. | Medium | Could be addressed by userContext prepend |
| C-GAP-9 | maxBudgetUsd: 0.50 per query hardcoded. Long agentic tasks may hit the cap mid-flow. No UI to adjust. | Low | bot-service.mjs:182 |
| C-GAP-10 | Discord 8MB attachment limit unhandled — batch send fails entirely if any one file exceeds. No per-file retry or fallback. | Medium | discord-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