Channel Activation & Group Routing ("The Oracle")
By default, every message Neo is admitted to see gets sent straight to the main neo agent — the channel connectors are "always-on, always-neo." That's fine for a private 1:1 chat with the owner, but it doesn't work once you want to connect Neo to a group: a group chat has many senders, most messages aren't meant for Neo at all, and different groups (or different keywords) often want different destinations — a dedicated research persona, a scheduled workflow, or just a quick one-shot answer instead of a full agent turn.
Channel routing lets you declare, per chat (or per sender, or per keyword), whether Neo should even be listening, when it should wake up, and what it should do when it does. It answers the owner's own framing of the need: connect a specific group; activate only on a trigger/keyword/mention; route a group or a trigger to a dedicated agent — not always neo.
This feature is entirely additive and opt-in. With it left off (the default), Telegram and WhatsApp behave exactly as before: the owner's 1:1 messages always reach neo, and nothing else is admitted.
Enabling it
Turn the feature on in settings.json:
{
"channels": {
"routing": {
"enabled": true
// "path": "/custom/path/channel-routing.yaml" // optional override
}
}
}channels.routing is a discoverability pointer only — it does not embed the bindings themselves. The actual rules live in a dedicated YAML document, ~/.neo/channel-routing.yaml by default (honors NEO_HOME), or wherever channels.routing.path points. A commented starting point is at templates/channel-routing.yaml.
If channels.routing.enabled is not true, none of the rest of this document applies — Telegram/WhatsApp never even read the bindings file.
The schema
The bindings file is a flat, ordered list — first-match-wins:
schemaVersion: 1
bindings:
- match: { ... }
allowFrom: [...]
admit: true
requireMention: true
triggers: [...]
action: { ... }
profile: "some-profile"
reactions: { ... }match — which chat(s) this binding applies to
match:
channel: telegram # or 'whatsapp'
chatId: "-1001234567890" # optional — omit or use '*' for "any chat on this channel"
chatKind: group # optional — 'group' | 'direct', narrows a wildcard chatId
topic: "42" # optional — Telegram forum thread idchatIdis optional. Absent, or the literal'*', means "any chat on this channel" — this is how you write "every group" without listing each one.chatKindnarrows a wildcard (or even a specific) match to only groups or only direct chats.topicscopes the binding to one Telegram forum thread within a chat. A binding with notopicmatches the whole chat regardless of thread; a binding with atopicmatches only that exact thread.
allowFrom — sender allowlist
allowFrom: ["123456789"]Optional list of sender ids (Telegram numeric user id as a string, or a WhatsApp participant JID). When present, the binding authorizes only those senders. Importantly: a sender who isn't in allowFrom does not get admitted "to the wrong agent" — the binding simply doesn't match for them at all, and resolution falls through to the next binding in the list (first-match-wins). This is how you write "user X gets the research agent; everyone else in the group is ignored (or falls through to a more general binding placed after this one)."
Absent allowFrom means the binding authorizes any sender in the chat(s) it matches — today's default two-level allowlist (owner-1:1, or "anyone in this admitted group").
admit — default true
Set to false to explicitly exclude a chat that would otherwise match (e.g. carve an exception out of a wildcard binding placed after it). Rarely needed.
requireMention — default true for groups (owner-1:1 never requires it)
requireMention: trueGroups default to "quiet" — Neo won't respond to every message, only when summoned. Direct/1:1 chats don't use this at all (the owner's 1:1 always activates, unchanged). When requireMention is true (or omitted), activation requires any one of: an @-mention of the bot, a reply to one of the bot's own messages, or a matching trigger (see below). Set it to false to make the binding always activate once admitted (no mention/trigger needed) — useful when the whole point of the group is "everything in here goes to Neo."
WhatsApp has no native @-mention concept the connector reads today, so WhatsApp groups rely on reply-to-bot and triggers to activate rather than an @-mention.
triggers — keyword/prefix/regex activation
triggers:
- kind: prefix
value: "research:"
caseInsensitive: trueThree kinds:
keyword— the value appears anywhere in the message (word-boundary matched where sensible, so"neo"doesn't fire inside"neoprene").prefix— the message starts with the value (after trimming leading whitespace) — e.g.neo:orresearch:at the start of a line.regex— the value is compiled as a regular expression and tested against the message text.
All three support caseInsensitive: true. A malformed regex trigger is dropped at load time (logged), never crashes anything — the rest of the binding's triggers still work.
action — what happens on activation
This is the heart of the "not always neo" redesign. A matched, activated binding resolves to exactly one of three action kinds:
agent (the default if action is omitted entirely):
action:
type: agent
agentId: research-agent # optional — omitted means 'neo' (today's behavior)Dispatches to an agent persona (~/.neo/agents/<agentId>.md). Each non-default agent gets its own isolated session per chat (see "Session scoping" below), so it keeps its own conversation history independent of neo's.
workflow:
action:
type: workflow
workflowId: daily-brief
# args: { ... } # accepted, but see the limitation belowFires a named workflow (~/.neo/workflows/<workflowId>) at daemon tier, fire-and-forget — a good fit for "run this pre-built job" rather than a live conversational turn.
Known v1 limitation —
argsis accepted but not forwarded. The schema accepts an optionalargsobject on aworkflowaction, but as of this wave it is not actually passed through to the workflow run. The daemon's workflow dispatcher (DaemonWorkflowHost.runActionById) currently only dispatches a workflow by id — it has no parameter for injecting per-call arguments, so a workflow always resolves its own inputs from its own definition. Do not author a binding expectingargsto reach the workflow; ifrunActionByIdgains anargsparameter in a future wave, this limitation will be lifted and this note updated.neo doctorwill warn (not fail) if it sees aworkflowbinding withargsset, precisely to catch this expectation mismatch before it surprises you.
llm:
action:
type: llm
# prompt: "custom instruction" # optional — overrides the inbound message text
# model: "some-cheap-model-id" # optional — overrides the default cheap-tier modelA one-shot LLM call — no tools, no session, no persisted history. prompt, if set, replaces the inbound message text as the model input; otherwise the message text itself is used. model, if set, overrides the default cheap-tier model selection. Good for "just answer this" without spinning up a full agent turn.
profile — optional tool-policy clamp
profile: mobile_minimalResolved via the same profile loader used elsewhere (loadProfile) and applied as the run's toolPolicy. Only meaningful for type: agent actions — a workflow run is already daemon-tier, and llm actions have no tools at all, so profile is a no-op for those two. An unknown profile name degrades gracefully (logged, no crash, the run proceeds without a policy override) rather than dropping the message.
reactions — optional per-binding override of the ack/onDone lifecycle
reactions:
ack: "👀" # or 'off' to place no ack reaction at all
onDone: remove # 'swap' | 'keep' | 'remove'
done: "👍" # only used when onDone: swap
error: "😱" # only used when onDone: swapSee the dedicated Reactions section below for the full config shape, resolution order, and the Telegram allowed-emoji constraint. Per-binding reactions overrides the channel's global channels.<channel>.reactions default, field by field.
How a message gets routed (resolution order)
Every inbound message goes through the same ordered decision, regardless of channel:
- Self-echo — if the message is the bot seeing its own outbound message (Telegram:
senderId === botId; WhatsApp: Baileys'fromMe), it's dropped immediately. No exceptions, checked first. - Admission — the message is admitted if either (a) it's the owner messaging Neo 1:1 (unchanged, no routing config needed), or (b) it structurally matches the first binding, in file order, whose
matchfits the chat and whoseallowFrom(if any) includes the sender. A binding that structurally matches the chat but excludes the sender viaallowFromdoesn't count as a match at all — the search keeps going to the next binding. If nothing admits the message, it's dropped. - Activation — the owner's 1:1 messages always activate (unchanged). For anything admitted via a binding, activation additionally requires
requireMentionto be satisfied: an @-mention, a reply-to-bot, or a matching trigger — unless the binding setrequireMention: false, in which case it always activates once admitted. Admitted-but-not-activated messages are silently skipped (not an error, just quiet). - Action resolution — once activated, the action is exactly the matching binding's
action(or{type: agent, agentId: 'neo'}for the owner-1:1 fallback with no matching binding — today's behavior, unchanged).
Ordering matters. Because it's first-match-wins, put more specific bindings (a particular sender, a particular chat) before more general ones (a wildcard chatKind: group). A specific-sender binding placed earlier in the list routes that sender differently than everyone else who falls through to a later, more general binding.
Worked examples
Every group, mention-gated, routed to the default agent:
- match: { channel: telegram, chatKind: group }
requireMention: true(No action needed — omitting it defaults to {type: agent, agentId: 'neo'}.)
Only one specific group, routed to a dedicated research agent:
- match: { channel: telegram, chatId: "-1009876543210" }
action: { type: agent, agentId: research-agent }A trigger that only fires for one user, ignoring everyone else in the chat:
- match: { channel: telegram, chatId: "-1009876543210" }
allowFrom: ["123456789"]
triggers:
- kind: prefix
value: "research:"A keyword that fires a workflow (e.g. "morning brief" via a keyword in a group):
- match: { channel: telegram, chatId: "-1005551234567" }
requireMention: false
triggers:
- kind: prefix
value: "daily:"
action: { type: workflow, workflowId: daily-brief }A prefix that gets a quick one-shot LLM answer, no agent turn:
- match: { channel: telegram, chatKind: group }
triggers:
- kind: prefix
value: "ask:"
action: { type: llm }See templates/channel-routing.yaml for all of these combined into one copy-pasteable starting file.
Session scoping
A binding's agent action that names a non-default agentId gets its own session, isolated from neo's session in the same chat — internally this is keyed as `${chatId}#${agentId}` rather than sharing chatId alone. You don't need to configure this; it's mentioned here only so it's not surprising that two different agents active in the same group maintain two independent conversation histories. There's no user-facing setting for this key today.
Group privacy — non-neo personas get no owner memory by default
A persona routed to via a channel-routing agent action (e.g. research-agent, or a group-facing persona like jester) runs in a group session that other chat members can see. To avoid leaking the owner's private profile — identity, preferences, working style, reflections, on-call responsibilities, etc. — into that shared context, owner-memory recall (<relevant_memories> prefetch injection) is OFF BY DEFAULT for every agent except the built-in neo agent.
This is independent of channel routing itself — it's a property of the agent definition (~/.neo/agents/<agentId>.md), so it applies whether the persona is reached via delegate, a direct neo --agent <id> run, or a channel-routing agent action:
---
name: jester
description: ...
memory: off # default when omitted — no owner-memory recall injected into this persona's turns
---To opt a specific persona back in (e.g. a private specialist agent you only ever talk to yourself, not a shared-group persona), set memory: on in its frontmatter:
---
name: comms-drafting
memory: on # opt-in — this persona's job explicitly requires the owner's profile/preferences
---memory: on/off gates both avenues into the owner memory store, so a single frontmatter setting is all you need:
- Passive — the automatic per-turn
<relevant_memories>prefetch (described above). - Active — the on-demand
recall-memorytool. Even if a persona listsrecall-memoryin itsallowedTools, the tool is structurally stripped from that persona's tool registry at boot whenmemoryis noton(for any non-neoagent), so the model never sees it and cannot call it. You do not have to remember to also removerecall-memoryfromallowedTools— leaving it in is harmless; the gate removes it.
Both avenues share one predicate (isOwnerMemoryInjectionAllowed, src/core/agent-loop/owner-memory-gate.ts); the active-side strip lives in src/runtime/embedded/owner-memory-tool-gate.ts and is applied where the persona's scoped registry is built (src/runtime/embedded/index.ts). Setting memory: on restores both the prefetch and the recall-memory tool for that persona.
Reactions
Every inbound message Neo dispatches gets an emoji reaction on the user's own message — the fastest possible "I've got it" signal, faster than the typing indicator and without adding a message to the thread. What emoji is used, and what happens to it once the turn finishes, is fully configurable (an owner-approved feature) — both globally per channel and per binding.
The config shape
reactions:
ack: "👀" # emoji, or 'off' to place no ack reaction at all. Default: '👀'.
onDone: remove # 'swap' | 'keep' | 'remove'. Default: 'remove'.
done: "👍" # swap-to-on-success emoji. Only used when onDone: swap. Default: '👍'.
error: "😱" # swap-to-on-failure emoji. Only used when onDone: swap. Default: '😱'.ack— the "seen"/engaged reaction placed the instant a message is dispatched.'off'disables the reaction entirely (the "typing…"/presence indicator is a separate affordance and is unaffected by this setting).onDone— what happens to the ack reaction once the turn completes:swap— replace it with adoneorerroremoji (this was the only behavior before this feature existed).keep— leave the ack reaction exactly as it was placed.remove— clear the reaction entirely once the turn finishes. This is the default. It's a deliberate change from the old implicitswapbehavior — the owner's call was that a lingering emoji per message is noisier than useful once the reply itself is visible in the chat.
done/error— only consulted whenonDone: swap.
Where it can be set
Global, per channel, in
settings.json:jsonc{ "channels": { "telegram": { "reactions": { "onDone": "swap" } }, "whatsapp": { "reactions": { "ack": "off" } } } }Per binding, in
channel-routing.yaml(see thereactionsfield under## The schemaabove) — only applies to messages that matched that binding.
Resolution order, field by field (not as one atomic block — a binding can override just ack while still inheriting the channel's global onDone, for example): the matching binding's reactions → the channel's global channels.<channel>.reactions → the built-in defaults above. A message that dispatches via the owner-1:1 fallback (no binding matched) always uses the global default (or the built-in default, if no global is set either).
Telegram's allowed-emoji constraint
Telegram's Bot API only accepts a fixed set of standard emoji for setMessageReaction — arbitrary or custom emoji are rejected outright. Any configured ack/done/error emoji for a Telegram binding or the global channels.telegram.reactions block is validated against this allowed set at load time. An emoji that isn't in the set degrades to the built-in default for that field (logged at warn) — it never crashes the connector, and it never silently breaks the ack lifecycle either (you'll see the warning in the logs if a configured emoji got rejected).
WhatsApp has no such restriction — Baileys accepts any emoji string for a reaction, so no validation is performed for channels.whatsapp.reactions or a WhatsApp binding's reactions. Removing a WhatsApp reaction is implemented as reacting with an empty string (there's no separate "clear reaction" API on WhatsApp).
Telegram group setup — enabling keyword/prefix triggers (not just @mentions)
By default, a Telegram bot added to a group only receives messages that are an @-mention of the bot, a reply to one of the bot's own messages, or a bot command — this is Telegram's privacy mode, and it's ON by default for every bot. If you want triggers (keyword/prefix/regex) to activate a group binding, Telegram's privacy mode must be turned off, or the keyword/prefix messages will simply never reach Neo in the first place — no amount of channel-routing.yaml configuration can work around a message the bot never receives.
To turn it off:
- Open a chat with @BotFather.
/mybots→ select your bot → Bot Settings → Group Privacy → Turn off.- Remove the bot from the group and re-add it. Telegram caches the privacy setting per membership — flipping the BotFather toggle alone does not retroactively change how the bot behaves in a group it's already a member of. The bot must be removed and re-added (or the group re-created) for the new setting to take effect.
With privacy mode ON, the bot only ever sees @-mentions/replies/commands — requireMention: false and triggers in a binding are effectively inert for that group, since the underlying keyword/prefix messages never arrive at all. This is intentional design on Telegram's part, not a bug in Neo: Neo's own ingress gate (decideIngress — admission, then activation via mention/reply/trigger) is what's supposed to decide activation, not Telegram's privacy mode. Turning privacy mode off hands that decision back to Neo, which is the intended setup for any group binding that relies on triggers.
Safety notes
- Off by default.
channels.routing.enableddefaults tofalse; nothing above changes behavior until you opt in. - A broken bindings file degrades safely, never crashes. A missing file, invalid YAML, or a file that fails schema validation all fall back to an empty routing config (logged at
warn), which is equivalent to routing being off — it never takes the daemon or a connector down. - An unknown
agentIddrops just that one binding. If a binding'sagentaction names anagentIdwith no corresponding~/.neo/agents/<id>.mddefinition, that binding is dropped at load time (logged) — the rest of the bindings file still loads and works normally. workflowactions still go through the daemon's existingdaemon.autoActionsallowlist, fail-closed — a channel-routing binding cannot make the daemon run a workflow it wasn't already allowed to run.neo doctor(below) helps you catch a misconfigured or surprising binding before it's exercised for real.
neo doctor integration
neo doctor includes a channel-routing check when channels.routing.enabled is true. It flags:
- routing enabled but no bindings file found on disk (falls back to owner-1:1 only — probably not what you intended),
- any
workflowbinding that setsargs(the accepted-but-not-forwarded limitation described above).
This check only ever warns — a routing misconfiguration is a config-quality issue, not something that should make neo doctor's exit code non-zero the way a broken daemon lock would.
Observability
Every inbound message that reaches decideIngress (both connectors) emits exactly one structured channel_ingress decision-log entry — for dispatch, drop, and skip/observe alike, never just the "interesting" outcomes. No message text/content is ever logged, only routing metadata.
Event: {type:'decision'}, data.decision === 'channel_ingress', data.reason a space-delimited key=value string:
outcome=dispatch reason=admitted chat=-1009876543210 kind=group action=agent agent=research-agent
outcome=drop reason=not-admitted chat=19995550000@s.whatsapp.net kind=direct
outcome=skip reason=no-activation chat=-1009876543210 kind=groupFields: outcome (dispatch/skip/drop/observe), reason (the IngressDecision.reason string — e.g. not-admitted, no-activation, admitted, owner-direct, self-echo), chat (the chat id/JID as-is), kind (direct/group); on dispatch only, additionally action (the action type: agent/workflow/llm) and agent (the resolved agentId, or - for workflow/llm actions).
Levels: info for dispatch and drop (a drop is security-relevant — worth seeing without turning on debug logging); debug for skip/observe (routine "admitted but not activated" traffic in a busy group would otherwise flood info logs).
Metrics (src/observability/channel-metrics.ts): channel_ingress_total{channel, outcome} (counter, every message) and channel_dispatch_total{channel, action_type, agent} (counter, dispatch only). A channel_action_settled_total{type, state} counter (state: completed/failed/skipped/unavailable) also records the daemon's run-action dispatcher (workflow/llm actions fired by a channel-routing binding — runtime/agent-cli.ts's handleRunAction) settling.
What's wired end-to-end vs. decision-log-only
The channel_action_settled_total metric (daemon run-action dispatch) has a real MetricsSink reachable — it runs inside the daemon process, which already builds one (cli/commands/daemon.ts) and passes it straight through.
The per-message channel_ingress/channel_dispatch_total metrics do not reach a live MetricsSink today: both connectors (neo-tg, neo-wa) run as separate OS processes, spawned by the daemon's channel-supervisor (ADR-0002/ADR-0003's trust-boundary split) and talking to the agent engine only over IPC. setTelemetrySink/createMetricsRuntimeSink are wired only inside the embedded agent engine's own composition root (runtime/embedded/index.ts), which the connector process never runs — so a connector's own logger uses the default no-op telemetry sink, and there is no MetricsSink instance to record into. Every recordChannelIngress call in both main.ts files therefore passes metricsSink: undefined today; the function still emits the channel_ingress decision log (see above), which is the must-have half of this feature.
The decision log does not reach neo telemetry either — at least not yet. A connector's stdout (including its JSON channel_ingress lines) is captured line-by-line by the daemon's channel-supervisor and appended verbatim to ~/.neo/daemon/daemon.log — a plain-text operational log, not the JSONL file TelemetryQueryService reads (config.telemetry.file.path, typically ~/.neo/telemetry/events.jsonl). So today, channel_ingress events are visible by grepping daemon.log, but do not show up in neo telemetry queries or scorecards.
Closing that gap cleanly would mean wiring createTelemetryRuntimeSink/createMetricsRuntimeSink (runtime/telemetry-runtime.ts) directly into startTelegramGateway/startWhatsAppGateway (both already call loadConfig()) — technically straightforward, but out of scope for this pass because it would change what every existing log statement in both connectors does (not just this new event), and a large share of the existing connector test suite mocks loadConfig() to return a bare {settings: {...}} with no .telemetry field, which those two helpers dereference unconditionally. A follow-up should land the sink wiring and the test-mock update together.
Testing safely
Never test channel-routing bindings against your live production bot token. Telegram only allows one long-poll connection per token, and testing routing rules inherently means sending messages you don't want your real bot/owner-chat to react to. Create a separate TEST bot (via @BotFather) and point a second, disposable connector configuration at it while you iterate on channel-routing.yaml. Only roll a verified binding over to the production bot once you're confident it does what you expect.