Engagement — when the bot answers, and how

Engagement is the third question of a bot’s chat frame (chat-access.md): the frame first decides which role serves the chat and who the person is there (their tier), and only a member or an owner of a chat some role serves ever reaches engagement. Strangers and blocked people never do — engagement does not see them, so it never has to filter them.

Engagement answers, per admitted message, whether the bot reacts and how. Like the rest of the frame it is data first, code over it:

  • Data — the chat’s rule in constitution/access.yaml: engage (addressed · always · never), plus the knobs every engaged turn of that chat carries — agent (agent scope) and context (the room context block). Hot: an edit applies on the next message.
  • Code — engage() in the bot’s policy (defineChatPolicy({ engage })): a brain function from normalized signals to a verdict. undefined defers to the rule.

The two axes

  • What it reacts to — a hard pre-dispatch gate. Returning “ignore” short-circuits the message before any cost (no typing indicator, no media download, no agent run).
  • How it reacts — an optional free-form steer string injected into the agent turn as an instruction (e.g. “reply as a course mentor”, “summarize then ask one question”). This is the engine’s universal “how” mechanism: it gives you full freedom over what the agent is told. What the agent can do with that instruction is its tool surface — its chat’s role (see “Honest scope” below).

The data tier — engage, agent, context

# constitution/access.yaml
bots:
  nova:
    default:                       # every chat of this bot
      context: { peers: false }    # hide this brain's other bots' replies
    chats:
      "tg:chat:-1001234567890":
        role: team-ops             # required — the frame serves a group only by its own rule
        members: everyone
        engage: always             # addressed | always | never
        agent: chat                # chat | thread — one agent for the room
        context: { thread: 20, from: all }
FieldValuesEngine default
engageaddressed — an @mention, a text_mention of the bot, a reply to one of its messages · always · neverDM always, group addressed
agentchat — the chat-level agent serves everything · thread — a per-thread agent inside a threadby thread kind: thread for forum topics and channel-discussion threads, chat for DMs and plain-group reply chains
context.threadrow cap of the room context block, or false for none10
context.peersshow this brain’s OTHER bots’ repliestrue
context.frommembers — only members’ and owners’ lines · all — everyone in the room, strangers includedmembers

context is a block: a tier that sets it replaces it whole, and the keys it leaves out come from the engine default (so context: { peers: false } keeps thread: 10, from: members). The tiers fold engine default → bots.<bot>.default → bots.<bot>.dm | chats.<id> → policy.chat(); the field-by-field picture with the tier behind each value is access_overview, access_explain or the dashboard’s Bots page (/chats).

A rule’s agent and context apply to every turn the bot engages in that chat — including the ones your engage() never shaped (it deferred with undefined, returned true, or does not exist) — so you never re-implement the gate just to set a knob. A decision’s own agent / context.thread / context.peers win field by field: { engage: true, context: { thread: 5 } } keeps the rule’s peers. An ignored message is untouched.

The code tier — engage()

// omnislash.config.ts
import { defineProject } from '@omnislash-ai/config/public';
import { chatPolicy } from './features/team/chat-policy.ts'; // your policy module

export default defineProject({
  name: 'my-brain',
  bots: [{ name: 'my-brain', tokenEnv: 'TELEGRAM_BOT_TOKEN', policy: chatPolicy }],
});
// features/team/chat-policy.ts
import { defineChatPolicy } from '@omnislash-ai/config/public';

export const chatPolicy = defineChatPolicy({
  engage(s) {
    // s: EngagementSignals — fully normalized, platform-agnostic.
    // return: boolean | EngagementDecision | undefined
  },
});

Chat behavior lives on the bot — there is no top-level engagement:, and the retired bots[].engagement / bots[].engaged keys fail the load with their new home in the message. A single-bot brain that tunes nothing omits bots entirely and gets the engine default. engage() is written beside chat() and sender() (the frame’s other two functions); see chat-access.md for those.

Signals (input)

EngagementSignals (@omnislash-ai/types/chat-policy) is everything the frame already resolved about the chat and the sender, plus the message:

interface EngagementSignals {
  // ── the chat (what chat() saw) ──
  surface: { kind: 'telegram'; chatId: string; botId: string }; // delivery target
  self: string;                 // THIS bot's persona name (multi-bot election)
  roster: readonly string[];    // all participating persona names, declaration order
  scope: 'dm' | 'group';        // 1:1 vs any multi-party chat
  chat: SubjectId;              // the chat as a principal: 'tg:chat:<id>' — what access.yaml keys by
  chatTitle?: string;           // group title — display data; match by `chat`
  // ── the sender (what sender() saw) ──
  sender: {
    id: SubjectId;              // 'tg:user:<id>', or 'tg:chat:<id>' when sent on behalf of a chat
    username?: string;
    name?: string;
    onBehalfOfChat?: 'self' | 'linked' | 'other'; // anonymous admin · linked-channel auto-forward · posted as another chat
  };
  rule: ChatRule;               // the chat's resolved rule — every data tier + chat()
  memberStatus(): Promise<'creator' | 'admin' | 'member' | 'restricted' | 'left' | undefined>;
  tier: 'owner' | 'member';  // engage() never runs for a stranger or a blocked person
  // ── the message ──
  addressed: boolean;           // @mention / text_mention / reply-to-bot
  automaticForward: boolean;    // channel post auto-forwarded into a discussion group
  senderChat?: { id: number | string; title?: string; username?: string };
  text?: string;                // message text, when present
  media?: readonly MediaKind[];      // carrier media kinds, attachment order
  replyToMedia?: readonly MediaKind[]; // replied-to message's media kinds
  threadRoot?: {                // the thread the message arrived in, when any
    messageId: number | string; //   platform id of the thread root
    kind: 'topic' | 'thread';   //   forum topic vs reply / discussion thread
    senderChat?: SenderChat;    //   the channel, for a channel-discussion thread
    channelPost?: boolean;      //   root is a linked-channel auto-forward
  };
  messageId?: number | string;  // inbound message's platform id (Telegram message_id)
  onBehalf?: {                  // present ONLY for business messages (Secretary Mode)
    author: 'owner' | 'counterpart';
    ownerRepliedAt?: number;    // epoch ms of your last manual reply in THIS chat
  };
}

tier is typed as the full AccessTier, but by construction only owner and member arrive. s.sender.id and s.tier let a policy decide by person (“answer Алекс only when addressed, even where the room is always”) without ever matching a username — usernames can be re-registered. s.rule.engage is what the bot would do if you returned undefined. memberStatus() asks the platform about the sender’s status in THIS chat (cached 5 minutes) and returns undefined where the platform cannot say — the bot is not an admin there.

media is the media trigger (s.media?.includes('audio') → a voice post; a MediaKind is 'image' | 'audio' | 'video' | 'document'). threadRoot lets a policy decide by thread identity — “any message in a thread rooted at MY channel’s post” (s.threadRoot?.senderChat?.id), even when the carrier itself is a plain user reply. The engine fills it from the carrier when it replies directly to the root; for deeper replies it classifies from its chat log (best-effort — a root it never observed keeps only messageId/kind).

onBehalf marks a message the bot handles on your behalf in one of your own private chats. Your policy only ever sees author: 'counterpart' — your own messages are recorded, never gated. See secretary-mode.md.

Every field is a primitive, the surface, or an opaque id — no platform (Telegram, …) type ever reaches your policy. A non-Telegram adapter normalizes automaticForward to false and leaves senderChat unset.

self + roster exist for multi-bot brains (one brain, several Telegram personalities — declare bots[]). Every personality evaluates this policy independently with no shared state, so to make exactly one of them respond to un-addressed group chatter, elect by name (self vs an ordering over roster) — never by anything Telegram-specific. A single-bot brain gets roster: [self], so the election is trivially “me”. See use case 4.

Verdict (output)

type EngagementVerdict =
  | undefined                                       // defer to the rule's `engage`
  | boolean                                         // engage / ignore shorthand
  | { engage: false;
      transcribe?: { autoPost?: boolean } }         // ignore — ambient actions still apply
  | { engage: true;
      steer?: string;                               // instruction into the agent turn
      toolContext?: Record<string, unknown>;        // brain bag carried to this turn's tools
      agent?: 'chat' | 'thread';                    // agent scope for this turn
      context?: { thread?: number | false;          // context-block row cap / off
                  peers?: boolean };                // show other bots' replies
      attach?: { context: string;                   // feature contexts this turn is about
                 entity?: string }[];
      transcribe?: { autoPost?: boolean } };        // ambient media action
  • undefined (or a bare return;) — defer to the chat rule’s engage for this message. This is what lets you widen/narrow selectively: only return a concrete verdict for the messages you care about.
  • true / false — engage / ignore, no steer.
  • A full decision — engage with a steer instruction and/or a toolContext bag (see “Per-turn tool context” below), or ignore.
  • attach — link the engaged turn to feature contexts: [{ context: 'ticket', entity: 'T-481' }] for an id the message names, a window your code keeps, anything a context’s when cannot say. The role’s contexts: must admit each, a chat pin holds it to the bound entity, and it stays attached for the context’s keep turns; an unknown context is dropped with a logged reason (contexts.md § 2.3).
  • agent — which agent serves the turn. 'thread': a turn inside a thread gets a per-thread agent (same persona, fresh session, warm-started from the chat log; created on the thread’s first engaged turn). 'chat': the chat-level agent serves everything. Omit for the rule’s agent, and absent that the engine default — thread for forum topics and channel-discussion threads, chat for DMs and plain-group reply chains. A thread that already has its agent keeps routing to it regardless of this knob (the knob controls bootstrap on an unbound thread, not routing). The auto-forwarded channel post itself opens its thread: engaging it creates the per-post agent, and the comments continue that same session.
  • context — tune the turn’s thread/chat context block (the “what the room said since your last turn” injection assembled from the engine’s chat log; group chats only). Each key you omit keeps the rule’s value:
    • thread — a number caps how many log rows it may inline (engine default 10; a thread’s root post always rides on top for the thread’s first turn), false skips the block for this turn entirely. Skipped/capped rows stay reachable via telegram_history.
    • peers — show the replies of your brain’s OTHER bots in the same room (engine default true). Telegram never delivers one bot’s messages to another, so without this a bot summoned mid-thread sees the humans’ half of a conversation they had with another bot. The engine reads those replies from its own log and labels each with the persona name (nova:; the bot’s own replies read muse (you):). false keeps the humans’ messages but hides the peers’ replies, and the block’s header says so: [Thread so far — 9 messages; 4 replies from nova not shown]. Only in a supergroup — a basic group numbers messages per member, so there a bot reads its own lane and sees no peer replies either way.
    • Whose lines the block carries is the rule’s context.from, not a per-turn knob: under members (the default) a stranger’s line never reaches the agent — a stranger’s text is a prompt-injection channel — and the header counts what it left out; all opts a chat into everyone’s lines. Every line names its sender by numeric id.
  • transcribe.autoPost — ambient media action, valid on BOTH arms: when the observed group message / channel post carries a voice or video, the engine transcribes it and posts the transcript (🎤 blockquote) as a reply to that message — inside a channel-discussion thread the reply lands in the thread, so the channel becomes searchable text for the humans too. It only fires when you did NOT engage (an engaged turn already echoes its transcript). Off by default engine-wide; turn it on per chat (a journal channel, say).

One transcript per message, whatever your roster. A group hands the same voice post to every persona of a multi-bot brain, and each engaging persona serves its own turn over it — so both the engaged echo and the ambient auto-post contend for a single per-message claim, and exactly one 🎤 quote reaches the chat. You do not have to elect a transcriber in your policy: let every persona engage as it should, and the engine posts the quote once. Which persona wins is first-come and not worth designing around — the quote carries no persona voice. Losing costs a persona nothing else: it still serves its turn, and it still warms the transcript cache for the thread’s later turns. DMs never contend (each persona has its own message stream there).

The policy may be async and may read private ids from your brain’s env.

Default behavior

With no engage() (or on undefined), the rule’s engage decides. With the engine defaults that reads, per scope:

ScopeEngageContext blockAgent scopeAuto-post
DMalwaysnone (DMs are not logged)chatoff
Plain groupwhen addressedlast 10 rows from members and owners, other bots’ replies includedchatoff
Channel post (auto-forward)never — it addresses nobody; widen via automaticForwardnone (the post IS the turn)per-thread agent (opens the post’s thread)off
Channel-discussion thread / forum topicwhen addressedlast 10 rows + thread root, other bots’ replies includedper-thread agentoff
Business chat (Secretary Mode)always — narrow via onBehalf, see secretary-mode.mdnonechatoff

These are the rows of a group the frame SERVES — a group is served only once a rule names its role (chat-access.md); an unserved group starts no turn whatever engage() returns. Returning undefined from your policy reproduces exactly the rule — so a policy that returns undefined everywhere is a no-op. Every knob you omit from a concrete decision also keeps its value from the rule, so { engage: true } changes nothing but the gate.

Use cases

1. Default (declare nothing). DMs and addressed group messages engage; unrelated group chatter is silently ignored.

2. Widen — engage on a watched channel’s auto-forward. A journal channel auto-forwards posts into its discussion group; you want the bot to react even though no one addressed it:

export const chatPolicy = defineChatPolicy({
  engage(s) {
    if (s.automaticForward && s.senderChat?.id === JOURNAL_CHANNEL_ID) {
      return {
        engage: true,
        steer: 'Reflect on this new journal entry as a companion.',
        // Optional: hand this post's id to your tools, so an out-of-band send
        // (a raw Bot-API sticker, say) can reply-thread to the exact post.
        toolContext: { replyToMessageId: s.messageId },
      };
    }
    return undefined; // every other message keeps the rule
  },
});

2b. The journal showcase — thread identity + steer + ambient transcripts. You only want the bot to answer when addressed, every voice post should still get its transcript posted into its discussion thread, and the journal persona should hold everywhere in the journal’s threads — including plain user replies deep in a thread, which threadRoot identifies even though the carrier is just a user message:

const isJournal = (s: EngagementSignals) =>
  s.senderChat?.id === JOURNAL_CHANNEL_ID ||            // the channel post itself
  s.threadRoot?.senderChat?.id === JOURNAL_CHANNEL_ID;  // any message in its thread

export const chatPolicy = defineChatPolicy({
  engage(s) {
    if (s.scope === 'group' && isJournal(s)) {
      return { engage: s.addressed, steer: JOURNAL_STEER, transcribe: { autoPost: true } };
    }
    return undefined;
  },
});

3. Narrow — by chat, or by person. Silencing a whole chat is data, not code: engage: never on its rule (or remove the rule, and the chat is not served at all). A silenced message still answers a card waiting for its sender — a question the agent asked them, a reply to a card — it just never starts a turn. Code earns its place when the answer depends on the message or the person — say, a room that answers everyone but one noisy member only when addressed:

      "tg:chat:-1001234567890": { role: team-ops, members: everyone, engage: always }
export const chatPolicy = defineChatPolicy({
  engage(s) {
    if (s.sender.id === NOISY_MEMBER && !s.addressed) return false;
    return undefined; // everyone else: the rule's `always`
  },
});

4. Multi-bot — elect exactly one ambient responder. Several personalities share a group. Addressed messages already route to the named bot (default gate), but for un-addressed chatter you want exactly one to speak — chosen deterministically, with no coordination. Each bot runs this independently; electing by a stable function of roster guarantees a single winner:

export const chatPolicy = defineChatPolicy({
  engage(s) {
    if (s.addressed) return undefined; // defer: the addressed bot answers (rule)
    if (s.scope !== 'group') return undefined; // DMs: the rule

    // Stable per-message election over the roster (declaration order). Hash the
    // chat id so the "spokesbot" is stable per chat but spread across personas.
    const ordered = [...s.roster].sort();
    const pick = ordered[hash(s.chat) % ordered.length];
    return s.self === pick ? { engage: true } : { engage: false };
  },
});

Elect on self/roster names, never on anything Telegram-specific. A single-bot brain (roster: [self]) always picks itself, so this is a no-op there. Every bot of the election must serve the group (a rule per bot, each naming its role). To let an un-addressed group message reach a bot at all, the bot must hear it: make it a group admin (no rights needed), or disable its privacy mode (BotFather /setprivacy, then re-add it to the group). access_overview and the doctor flag engage: always in a group the bot hears only when addressed.

Per-chat via env (the watched-id pattern)

Private ids your code compares against (the journal channel id) are secrets — they belong in .env, not in the committed omnislash.config.ts. The boundary is precise: omnislash.config.ts’s own module must not import the env proxy, but code it references by import may (see the runtime contract). So:

  1. Put the policy in a separate module (e.g. features/journal/chat-policy.ts) that imports your brain’s typed env re-export.
  2. Read the id inside the function body (runtime, per message — always safe).
// features/journal/chat-policy.ts
import { defineChatPolicy } from '@omnislash-ai/config/public';
import { env } from '../../env.ts'; // your brain's typed env re-export

export const chatPolicy = defineChatPolicy({
  engage(s) {
    if (s.automaticForward && String(s.senderChat?.id) === env.JOURNAL_CHANNEL_ID) {
      return { engage: true, steer: 'Reflect on this journal entry.' };
    }
    return undefined;
  },
});

omnislash.config.ts then import { chatPolicy } from './features/journal/chat-policy.ts' and passes it as bots[].policy — exactly the pattern tools: registerTools uses. A chat id that is not a secret can live in the data tier instead: a rule keyed by that chat in constitution/access.yaml.

Honest scope of “how” (steer)

steer gives you instruction freedom — it controls what the agent is told. It does not guarantee a workflow runs. “Run pipeline X” only works if your brain exposes a workflow-dispatch tool to its agent; the engine’s default dispatch tool ships disabled. Direct engine-level pipeline dispatch from the decision is a future extension, not part of this contract. Treat steer as a behavioral directive, not a guaranteed action.

Per-turn tool context (toolContext)

Where steer is the instruction channel (through the model), toolContext is the data channel (around it). Return a JSON-serializable bag with your engage decision and the engine carries it verbatim into every tool call of that turn — your tool reads it from its execution context:

// engage():
return { engage: true, steer: '…', toolContext: { replyToMessageId: s.messageId } };

// brain tool:
async execute(args, context) {
  const replyTo = context.auth.engagement?.toolContext?.replyToMessageId;
  // e.g. thread a raw Bot-API send: reply_parameters: { message_id: replyTo, ... }
}

Properties of this channel:

  • Engine-opaque. No engine code reads the keys; the schema is yours.
  • Deterministic. The bag never enters the model prompt: the LLM can neither lose it nor forge it. Delivery metadata (the trigger message id, a topic id) belongs here, NOT encoded into steer text and NOT taken as a model-supplied tool argument — which also preserves fixed-destination invariants on hitl-free capabilities (the model can’t redirect the send).
  • Turn-scoped. It rides the dispatched run’s auth (ToolContext.auth .engagement.toolContext); the next chat turn rebuilds it from its own verdict.
  • API agents today. The bag reaches tools of API-model agents (tools are injected in-process with the run’s auth). A CLI-model agent’s tools go through the MCP URL channel, which does not round-trip engagement yet — give the reacting persona an API modelRef (cheap models fit: the persona only picks an emoji).

Notes

  • The decision is computed once per message and cached, so your engage() runs exactly once even though both the typing pre-gate and the message handler consult it.
  • An engaged turn of a member spends one unit of the rule’s limit.turnsPerHour budget (per member, per chat, per clock hour; owners are never limited; a reply answering a card is free). Over budget the turn is dropped silently and the person is told once an hour, privately (chat-access.md).
  • Write engage() without side effects: the access_explain tool and the dashboard simulator call it to explain a decision.
  • The policy is carried by reference (file mode), like tools and ingest — it is not serialized and has no env-fallback form.
  • The rule’s context.thread is validated where the file loads: a non-negative integer or false.

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.