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) andcontext(the room context block). Hot: an edit applies on the next message. - Code —
engage()in the bot’spolicy(defineChatPolicy({ engage })): a brain function from normalized signals to a verdict.undefineddefers 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
steerstring 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 }
| Field | Values | Engine default |
|---|---|---|
engage | addressed — an @mention, a text_mention of the bot, a reply to one of its messages · always · never | DM always, group addressed |
agent | chat — the chat-level agent serves everything · thread — a per-thread agent inside a thread | by thread kind: thread for forum topics and channel-discussion threads, chat for DMs and plain-group reply chains |
context.thread | row cap of the room context block, or false for none | 10 |
context.peers | show this brain’s OTHER bots’ replies | true |
context.from | members — only members’ and owners’ lines · all — everyone in the room, strangers included | members |
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 barereturn;) — defer to the chat rule’sengagefor 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
steerinstruction and/or atoolContextbag (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’swhencannot say. The role’scontexts:must admit each, a chat pin holds it to the bound entity, and it stays attached for the context’skeepturns; 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’sagent, and absent that the engine default —threadfor forum topics and channel-discussion threads,chatfor 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),falseskips the block for this turn entirely. Skipped/capped rows stay reachable viatelegram_history.peers— show the replies of your brain’s OTHER bots in the same room (engine defaulttrue). 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 readmuse (you):).falsekeeps 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: undermembers(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;allopts 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:
| Scope | Engage | Context block | Agent scope | Auto-post |
|---|---|---|---|---|
| DM | always | none (DMs are not logged) | chat | off |
| Plain group | when addressed | last 10 rows from members and owners, other bots’ replies included | chat | off |
| Channel post (auto-forward) | never — it addresses nobody; widen via automaticForward | none (the post IS the turn) | per-thread agent (opens the post’s thread) | off |
| Channel-discussion thread / forum topic | when addressed | last 10 rows + thread root, other bots’ replies included | per-thread agent | off |
| Business chat (Secretary Mode) | always — narrow via onBehalf, see secretary-mode.md | none | chat | off |
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:
- Put the policy in a separate module (e.g.
features/journal/chat-policy.ts) that imports your brain’s typedenvre-export. - 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
steertext 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
engagementyet — give the reacting persona an APImodelRef(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
memberspends one unit of the rule’slimit.turnsPerHourbudget (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: theaccess_explaintool and the dashboard simulator call it to explain a decision. - The policy is carried by reference (file mode), like
toolsandingest— it is not serialized and has no env-fallback form. - The rule’s
context.threadis validated where the file loads: a non-negative integer orfalse.