Skip to content

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:

jsonc
{
  "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:

yaml
schemaVersion: 1
bindings:
  - match: { ... }
    allowFrom: [...]
    admit: true
    requireMention: true
    triggers: [...]
    action: { ... }
    profile: "some-profile"
    reactions: { ... }

match — which chat(s) this binding applies to

yaml
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 id
  • chatId is optional. Absent, or the literal '*', means "any chat on this channel" — this is how you write "every group" without listing each one.
  • chatKind narrows a wildcard (or even a specific) match to only groups or only direct chats.
  • topic scopes the binding to one Telegram forum thread within a chat. A binding with no topic matches the whole chat regardless of thread; a binding with a topic matches only that exact thread.

allowFrom — sender allowlist

yaml
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)

yaml
requireMention: true

Groups 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

yaml
triggers:
  - kind: prefix
    value: "research:"
    caseInsensitive: true

Three 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: or research: 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):

yaml
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:

yaml
action:
  type: workflow
  workflowId: daily-brief
  # args: { ... }   # accepted, but see the limitation below

Fires 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 — args is accepted but not forwarded. The schema accepts an optional args object on a workflow action, 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 expecting args to reach the workflow; if runActionById gains an args parameter in a future wave, this limitation will be lifted and this note updated. neo doctor will warn (not fail) if it sees a workflow binding with args set, precisely to catch this expectation mismatch before it surprises you.

llm:

yaml
action:
  type: llm
  # prompt: "custom instruction"   # optional — overrides the inbound message text
  # model: "some-cheap-model-id"   # optional — overrides the default cheap-tier model

A 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

yaml
profile: mobile_minimal

Resolved 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

yaml
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: swap

See 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:

  1. 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.
  2. 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 match fits the chat and whose allowFrom (if any) includes the sender. A binding that structurally matches the chat but excludes the sender via allowFrom doesn't count as a match at all — the search keeps going to the next binding. If nothing admits the message, it's dropped.
  3. Activation — the owner's 1:1 messages always activate (unchanged). For anything admitted via a binding, activation additionally requires requireMention to be satisfied: an @-mention, a reply-to-bot, or a matching trigger — unless the binding set requireMention: false, in which case it always activates once admitted. Admitted-but-not-activated messages are silently skipped (not an error, just quiet).
  4. 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:

yaml
- 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:

yaml
- 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:

yaml
- 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):

yaml
- 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:

yaml
- 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:

yaml
---
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:

yaml
---
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-memory tool. Even if a persona lists recall-memory in its allowedTools, the tool is structurally stripped from that persona's tool registry at boot when memory is not on (for any non-neo agent), so the model never sees it and cannot call it. You do not have to remember to also remove recall-memory from allowedTools — 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

yaml
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 a done or error emoji (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 implicit swap behavior — 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 when onDone: swap.

Where it can be set

  1. Global, per channel, in settings.json:

    jsonc
    {
      "channels": {
        "telegram": { "reactions": { "onDone": "swap" } },
        "whatsapp": { "reactions": { "ack": "off" } }
      }
    }
  2. Per binding, in channel-routing.yaml (see the reactions field under ## The schema above) — 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:

  1. Open a chat with @BotFather.
  2. /mybots → select your bot → Bot SettingsGroup PrivacyTurn off.
  3. 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.enabled defaults to false; 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 agentId drops just that one binding. If a binding's agent action names an agentId with no corresponding ~/.neo/agents/<id>.md definition, that binding is dropped at load time (logged) — the rest of the bindings file still loads and works normally.
  • workflow actions still go through the daemon's existing daemon.autoActions allowlist, 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 workflow binding that sets args (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=group

Fields: 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.