Channels — How Neo Listens on Your Phone
Neo's default interface is the CLI. Channels extend Neo to your phone and other messaging platforms while keeping the security properties of the core agent intact.
The current primary channel is Telegram. WhatsApp is an experimental Phase 2 channel not yet recommended for production use.
Architecture: the shared connector framework is ADR-0001. Since ADR-0002, a connector attaches to the always-on daemon's shared engine over a local IPC socket by default instead of spawning its own
neo --jsonchild — so Telegram, WhatsApp, and the CLIattachall drive one engine. The diagram below shows the isolated (spawn-a-child) mode; in attach mode thespawn(stdio)arrow becomes a socket connection to~/.neo/daemon/engine.sock. SetNEO_ENGINE=isolatedto force the spawn-a-child mode (e.g. to sandbox the experimental WhatsApp connector).Channel connectors attach to the channel endpoint (
engine.sock) and their runs are mobile-clamped.neo attachis the owner's terminal and connects to a separate owner-only endpoint (engine-owner.sock) that runs unclamped — a channel connector cannot acquire that owner posture. See Security and the ADR-0002 owner-posture addendum.
Architecture
┌──────────────┐ Bot API ┌──────────────┐ spawn(stdio) ┌─────────────────┐
│ Telegram │ ────────► │ neo-tg │ ─────────────► │ neo --json │
│ (phone) │ ◄──────── │ (grammY) │ ◄───────────── │ (child) │
└──────────────┘ └──────────────┘ └─────────────────┘
│
SQLite: ~/.neo/channels/telegram.db
(chat_id → sessionId)
Phase 2 — WhatsApp adds an identical column to the right of this picture,
using a separate neo-wa bin and its own Baileys socket / SQLite file.The gateway (neo-tg) is a long-running Node process. It speaks the Telegram Bot API via grammY (long polling — no public webhook URL needed) and talks to the agent runtime by spawning neo --json as a stdio child. The two processes communicate over NDJSON on stdin/stdout.
This process boundary is intentional. A bug in the grammY update loop or a Telegram API response cannot directly affect the agent runtime. The child only accepts typed NDJSON run requests — it never receives raw shell commands.
Configuration
~/.neo/channels.yaml
Create this file to configure channels. All fields except enabled have safe defaults.
channels:
telegram:
enabled: true
profile: mobile_minimal # built-in profile; or name a custom one below
# profiles: # uncomment to define custom profiles
# custom_safe:
# allow: [ChannelReply]
# ask: [WebSearch, MemoryRead]
# deny: [Bash, Write, Edit]Required environment variables
| Variable | Description |
|---|---|
NEO_BIN | Absolute path to the compiled neo binary (e.g. /opt/neo/dist/bin/neo.js). Used by neo-tg to spawn the child process. |
TELEGRAM_BOT_TOKEN | Token issued by BotFather. Prefer storing this via keytar (see below). |
OWNER_USER_ID | Numeric Telegram user ID of the authorized owner. Messages from any other user are silently dropped. Get this ID from @userinfobot on Telegram. |
Optional environment variables
| Variable | Description |
|---|---|
NEO_PROFILE | Override the tool profile name for this gateway run (e.g. mobile_relaxed). Takes precedence over channels.yaml. |
NEO_ENGINE | isolated forces the connector to spawn its own private neo --json child instead of attaching to the shared daemon engine (ADR-0002). Default (unset): attach when a daemon is available, else spawn a child. |
Storing the bot token with keytar
Rather than setting TELEGRAM_BOT_TOKEN in a shell environment, you can store it in the system keychain:
# Store (run once)
node -e "require('keytar').setPassword('neo', 'telegram-bot-token', 'YOUR_TOKEN')"
# The gateway reads it automatically on startup when the env var is absent.Tool profiles
Tool profiles control which of Neo's tools can run automatically, which require owner approval (inline keyboard), and which are hard-blocked regardless of what the LLM requests.
Two profiles ship built-in:
| Profile | Auto-allow | Ask (approval required) | Hard-deny |
|---|---|---|---|
mobile_minimal (default) | ChannelReply | WebSearch, MemoryRead, Schedule | Bash, Write, Edit, BashOutput, NotebookEdit |
mobile_relaxed | ChannelReply, MemoryRead | WebSearch, Schedule, Read, Glob, Grep | Bash, Write, Edit, BashOutput, NotebookEdit |
Profile source code: src/channels/_profiles.ts.
For the security rationale behind these choices — particularly why WebSearch and MemoryRead cannot both be auto-allowed — see docs/channel-threat-model.md.
Custom profiles can be defined in ~/.neo/channels.yaml under the profiles: key. They are validated on load via validateMobileSafeToolPolicy; unsafe combinations throw at startup, not at runtime.
Voice notes
Send a voice note (OGG/Opus format) to the bot. Neo downloads the file, transcribes it via the OpenAI Whisper API, and treats the transcript as the message text. The original audio is never stored persistently.
If transcription fails (API error, network issue, unsupported format), Neo receives a sentinel string [voice transcription failed] and continues the turn — it never crashes or hangs on a voice failure.
Security
For the full threat model, see docs/channel-threat-model.md.
Key security properties of the channel architecture:
The agent core never directly accepts network input. The gateway process is the sole network-facing component. It normalizes all inbound messages to typed NDJSON before passing them to the agent child over stdio. No raw network data reaches the agent runtime.
Permission denial happens in core, not in the gateway. The gateway attaches a toolPolicy to every run request. Neo's permission engine (src/permissions/engine.ts) evaluates the policy and hard-blocks denied tools before any execution occurs. Even if the gateway were compromised and sent a permissive policy, the built-in mobile_minimal deny list is a baseline that gateways are required to include.
The gateway runs as a separate OS process. A crash or exploit in the grammY event loop is isolated to the gateway process. The agent child continues to run, and the gateway can be restarted without losing conversation history (stored on disk in ~/.neo/sessions/).
A compromised connector cannot escalate to the owner posture. When connectors attach to the shared daemon engine (ADR-0002), the engine clamps every run on the channel endpoint (engine.sock) to the mobile-safe floor — so even a connector that sends a fully-permissive toolPolicy cannot auto-allow Bash/Write/Edit. The owner's own terminal (neo attach) runs unclamped, but only because it connects to a separate owner-only endpoint (engine-owner.sock); the engine grants the owner posture by which listener accepted the connection, never by a field in the request, and channel connectors are never wired to that endpoint. So influencing what a connector sends — the realistic prompt-injection / connector-compromise surface — cannot acquire owner permissions. (Full code execution as the owner user inside a connector process is out of scope; such an attacker already has the owner's local privileges. See the ADR-0002 addendum for the full threat model.)
Token rotation. If you lose your phone or suspect the bot token is compromised: immediately use BotFather to revoke and regenerate the token (/mybots → select bot → API Token → Revoke current token). Update keytar or the environment variable and restart neo-tg. There is no in-Neo mechanism to revoke an active Telegram session remotely.
Operational notes
The gateway binaries are plain Node scripts. They can be run in any of these ways:
Terminal (testing):
NEO_BIN=/opt/neo/dist/bin/neo.js \
TELEGRAM_BOT_TOKEN=... \
OWNER_USER_ID=... \
node /opt/neo/dist/bin/neo-tg.jsPM2: A reference ecosystem config is planned at docs/operational/pm2.example.cjs (created in a future task). That file is documentation only — Neo never imports it. Rough shape:
module.exports = { apps: [{ name: 'neo-tg', script: 'dist/bin/neo-tg.js', max_memory_restart: '500M' }] };systemd: A reference unit file is planned at docs/operational/systemd.example.service (created in a future task). The code has no dependency on either supervisor.
Graceful shutdown is handled automatically. neo-tg listens for SIGINT/SIGTERM, stops grammY's polling, and sends a termination signal to the child process before exiting.
WhatsApp (neo-wa) — experimental
⚠️ Experimental — unofficial protocol, real account-ban risk.
neo-watalks to WhatsApp via Baileys, an unofficial, reverse-engineered protocol — not the official WhatsApp Business API. Meta can ban the linked number for using it. Use a dedicated secondary WhatsApp number, never your primary account, and run for 7–14 days at owner-only volume before relying on it.Because of this,
neo channels pair whatsapprequires explicit confirmation on first pairing: either answeryto the interactive[y/N]prompt it shows (TTY only) or pass--experimentalto proceed non-interactively. Without a TTY and without--experimental, pairing refuses to start and exits non-zero. Once a device is paired, this confirmation is not asked again on reconnects/repairs.
What it is
A second gateway, structurally identical to neo-tg, that connects to WhatsApp via Baileys v7. It spawns its own neo --json child — neo-tg and neo-wa are independent processes with independent failure domains.
Required environment
| Variable | Format | Example |
|---|---|---|
WHATSAPP_PHONE_NUMBER | digits only, country code first, no + / spaces / dashes | 12025550123 |
WHATSAPP_OWNER_JID | <digits>@s.whatsapp.net | 12025550123@s.whatsapp.net |
NEO_BIN | absolute path to compiled neo.js | /opt/neo/dist/bin/neo.js |
WHATSAPP_OWNER_JID may also live in the OS keychain under service=neo-cli.channels, account=whatsapp-owner-jid.
First-run pairing
On the first run the SQLite auth state is empty, so Baileys produces a pairing code:
[neo-wa] Pairing code: ABCD-EFGHOn the secondary phone, open WhatsApp → Settings → Linked Devices → Link a device → Link with phone number and enter the 8-character code. Subsequent restarts skip this step and reuse the persisted creds in ~/.neo/channels/whatsapp-auth.db.
Hardening already in place
- Owner JID allowlist — only exact
<jid>@s.whatsapp.netmatches pass; group/newsletter/broadcast JIDs silently dropped (src/channels/whatsapp/allowlist.ts). status@broadcastfilter — runs before the allowlist; the gateway never even acknowledges status messages, never reads them, never writes to them. This avoids the ban-correlation surface called out in Baileys Issue #2309.- No streaming edits — outbound replies are buffered until
final_responseand sent as one message. WhatsApp's edit semantics are unreliable across iOS/Android/web, and editing churn is itself a ban-correlation signal. - 2–5 s outbound jitter — every
sendMessageis preceded by a random delay (src/channels/whatsapp/streaming.ts). - Approval prompt is text-based — vanilla WhatsApp has no inline keyboards, so the gateway sends
"1 = allow once / 2 = always / 3 = deny"and waits for the next inbound text. TTL is 5 min; timeout = auto-deny. - Custom SQLite
AuthenticationState— neveruseMultiFileAuthState(the upstream comment says it's not for production). All key writes go through one transaction perkeys.setcall. - Reconnection —
loggedOutandforbiddenare terminal (process exits non-zero, operator must re-pair). Everything else (connectionLost,restartRequired,timedOut, …) reconnects with exponential backoff (1 s → 30 s cap).
What you cannot do (yet)
- Group chats. Phase 2 ships single-owner only.
- Multiple owners. The allowlist accepts exactly one JID.
- Streaming edits.
- Sending status updates / broadcasts.
- WhatsApp Business interactive buttons or list messages.
Phase status
| Channel | Status | Notes |
|---|---|---|
| Telegram | Primary — Phase 1 | grammY long-polling; recommended for production use |
| Experimental — Phase 2 | Baileys v7; dedicated secondary number required; 7–14 day soak recommended before relying on it |