Chat Access — who may use the bot, and how it behaves
With no configuration at all, a bot serves only you. It answers every message in your DM with it and stays silent everywhere else. To let people use it, add it to a group, or change how it behaves, you have three ways in:
- tell the agent (“let the support group talk to the bot, but not with my tools”);
- open Bots (
/chats) on the dashboard; - edit
constitution/access.yamlyourself.
If the fields there cannot express what you want, write a function in the
bot’s policy.
Remember three things:
- The role belongs to the chat. Everyone in a chat talks to the same role,
with that role’s tools and memory. To keep your mail away from a group, give
the group a role without
email:*; there is no per-person rule for this. - Each person gets a tier:
owner(you),member,strangerorblocked. - Only an owner can approve (HITL), wherever the card appears. This cannot be configured.
Nobody in a group needs to be a Telegram admin to use the bot. The bot itself
does not need admin rights either, as long as “answer when addressed” is
enough. Make the bot an admin (it can have no rights at all) only if it must
hear every message in a group, or check membership (memberOf).
Who is who
These docs use one word for each kind of person:
- Owner — you, and anyone else with the
ownertier. There can be several; any of them can approve. - Primary owner — the first id in
TELEGRAM_ALLOWED_IDS. Gets the defaults: the default HITL chat, theownernotify channel, restart messages. - Member / stranger — the other tiers below: people the bot talks to on a rule’s terms, and people it does not serve.
- Counterpart — the other side of a chat your secretary bot answers for you (secretary-mode.md).
- Admin — whoever runs the server: the dashboard login (
ADMIN_USER/ADMIN_PASS_HASH), env, deploys, Grafana. Often an owner, but not by definition. - Brain author — whoever writes the brain; usually an agent.
A Telegram group’s own creator or admin is neither: in a served group
they are a member, maybe with more commands.
The frame — four questions, one place each
Every bot answers four questions about every inbound message:
| Question | The answer | Data — constitution/access.yaml | Code — bots[].policy |
|---|---|---|---|
| Which role serves this chat? | persona, tools, memory line, model | the chat rule’s role | chat() |
| Who is this person here? | a tier | identities + the rule’s members | sender() |
| When does the bot answer? | engage / steer / agent scope / context | engage, agent, context | engage() — engagement.md |
| What may they run and reach besides talking? | commands, and the capabilities their turn may reach | commands, capabilities | sender() → { tier, commands, capabilities } |
Each answer is resolved on one ladder. The most specific tier wins, field by field:
engine default ─▶ access.yaml bots.<bot>.default ─▶ bots.<bot>.dm | bots.<bot>.chats."tg:chat:<id>" ─▶ bots[].policy
context and limit are blocks. A tier that sets a block replaces it whole,
and any key it leaves out comes from the engine default. One resolver folds
the ladder. The bot’s own admission gate, the access_explain tool and the
dashboard simulator all call that same resolver, so an explanation can never
drift from what the bot actually does.
Tiers — a closed set, semantics owned by the engine
| tier | Conversation | Commands | Capabilities of their turn | Control buttons (/agents, run / pipeline / edit cards) | HITL approve / grant | HITL question |
|---|---|---|---|---|---|---|
owner | yes | all | the role’s, never capped | yes | yes | yes |
member | yes, through engage | the rule’s commands | the role’s, cut to the rule’s capabilities | no | no | only the ones addressed to them |
stranger | no; their line reaches the context block only under context.from: all | no | — | no | no | no |
blocked | no, and not even logged | no | — | no | no | no |
owner is global, because the control buttons steer the whole brain
(agents:deleteall deletes every agent, not one group’s). There is no
“owner of one group”. A group’s admin is a member with more commands.
The owners are:
- everyone in
TELEGRAM_ALLOWED_IDS(numeric ids only); this is the bootstrap identity, needed before anything else loads; - every
identitiesentry withtier: owner— co-owners are added here, never in env.
How a person’s tier is decided, in order:
A message sent on behalf of a chat is that chat’s: an anonymous admin posts
as the group itself (sender.id is the group’s tg:chat:<id>,
onBehalfOfChat: 'self'), a linked channel’s post reaches its discussion group
as an automatic forward (the channel’s id, 'linked'), and anyone may comment
as a channel of their own (that channel’s id, 'other'). Such a sender goes
through the same steps as a person, under the chat’s id.
- Anyone in
TELEGRAM_ALLOWED_IDSisowner. Nothing below runs. identities:ownerandblockedapply in every chat of every bot;membercounts only where the rule admits more than owners;strangerapplies everywhere.
- The rule’s
members:everyone→member;listed→ only people markedmember(step 2);owners→ nobody else;{ memberOf: [...] }→memberif the platform shows the person as creator, admin, member or restricted in one of those groups. The bot must be an admin there to see this. A chat is never shown as one.
sender(), if the bot has one.undefineddefers. It cannot make anyone anowner(anownerverdict is clamped tomember); grant that underidentities.
So a group under members: owners hears no anonymous admin and no channel post
unless identities lists that chat. Your own channel whose posts the bot answers —
a journal it reflects on — is listed as an owner:
identities:
"tg:chat:-1009876543210": { tier: owner, note: my journal channel }
Whoever can post as a listed chat runs at its tier: for a channel, its posting
admins; for a group, its anonymous admins. List a chat as owner only while
every one of them is an owner. Doctor Chat access names each chat listed as
owner so the trust stays visible.
The data tier — constitution/access.yaml
Before 1.21.0 the file was constitution/chats.yaml. Nothing reads that name now:
a brain that still keeps its rules there runs on the engine defaults (owners
only) until the file is moved — git mv constitution/chats.yaml constitution/access.yaml, or merge it into an existing access.yaml.
identities: # brain-global; numeric ids only (a person, or a chat that speaks)
"tg:user:111111111": { tier: owner, note: Maria — co-owner }
"tg:user:222222222": { tier: member, note: Alex }
"tg:user:333333333": { tier: blocked, note: spam }
bots:
nova: # a bots[] name from omnislash.config.ts
default: # this bot's defaults for every chat it is in
context: { peers: false }
unknownChats: ask # ask | ignore | leave
dm:
members: { memberOf: ["tg:chat:-1001234567890"] } # owners | listed | everyone | { memberOf }
role: support-helper # the role for NON-owners; your own DM always runs bots[].role
decline: The bot is for members of the customer support group.
chats:
"tg:chat:-1001234567890":
title: Customer support group # for people; rules match by id only
role: support-helper # required
members: everyone
engage: addressed # addressed | always | never
commands: [help]
limit: { turnsPerHour: 10 }
context: { from: members } # members | all — whose lines the agent sees
contexts: { ticket-queue: support } # feature contexts this group is about — contexts.md § 2.2
The file is part of the G-set (contract.md § 2). It is hot: the
bot re-reads it on every inbound event, so an edit applies to the next message
with no restart, and brain-sync commits it to git. The agent writes it with
access_update / access_widen, and the dashboard writes it with the same edits.
Both writes are compare-and-swap edits of the text, so every comment you wrote
survives.
Keys are SubjectIds from the identity axis the rest of the engine uses. A
person is tg:user:<positive id> and a group is tg:chat:<negative id>.
Another platform adds its own prefix.
Fields of a rule
| Field | Meaning | Engine default — DM | Engine default — group |
|---|---|---|---|
role | the role serving the chat — its persona, tools, memory line, model | your DM: bots[].role; anyone else: none (not served) | none — a group is served only once its rule names a role |
members | who besides owners may talk: owners · listed · everyone · { memberOf: [tg:chat:…] } | owners | owners |
engage | when the bot answers: addressed (an @mention, a reply to the bot) · always · never | always | addressed |
commands | what a member may run (bare names, no /); owners run everything | start, help | help |
capabilities | the ceiling of a non-owner’s turn: capability ids (research:read, email:send, …) — whatever the role and its live grants hold, a member’s turn reaches only these; '*' = no ceiling; owners are never capped. The member ceiling | none ([]) | none ([]) |
agent | agent scope of an engaged turn: chat · thread | by thread kind — engagement.md | ← |
context | the room context block: thread (row cap, or false), peers (this brain’s other bots’ replies), from (members · all) | — (DMs carry no block) | { thread: 10, peers: true, from: members } |
limit | turnsPerHour — turns the bot takes per member per hour; 0 = no limit | 30 | 30 |
decline | what a refused person is told in a DM; absent = silence | — | — |
browser | who may open this chat’s widgets in a plain browser, signed in with Telegram: audience (everyone the widget admits in Telegram) · owners · off (only inside Telegram). Widgets in a chat | audience (a business chat: off) | audience |
The connect flow (connect, tg_auth, tg_accounts, tg_default, tg_remove) is
never a member default: it acts on the brain’s one shared Telegram account pool,
so a member running it could list, replace or disconnect your own accounts.
Per bot, unknownChats controls what happens when the bot is added to a group
no rule serves: ask (the engine default), ignore or leave. See
Groups nobody configured.
Where each field may go:
bots.<bot>.defaultapplies to every chat of the bot, DMs included. It cannot name a role: a default role would leak into every group.bots.<bot>.dmcovers every DM of the bot. Itsroleserves the people it admits who are not owners; an owner’s own DM always runsbots[].role.bots.<bot>.chats."tg:chat:<id>"covers one group and must name itsrole. A private chat id is refused here; DM access is configured underdm, and per person underidentities.- Two keys belong to a group’s own entry only, never to
defaultordm:title(for people reading the file) andcontexts— the feature contexts the group is ABOUT, context id → entity key ({ client: acme }; no value for a context with no entity). Every turn in the group carries them, pinned to that entity, whatever the role’scontexts:list says (contexts.md § 2.2). Adding or moving one is a widening change. Bindings made at run time (context_bind) live in the database, not here;access_explainshows both.
Validation — closed world, fail fast
Validation runs at boot and on every access_update:
- A
bots.<name>that is not one of yourbots[]is an error. - So is a role no
*.agent.mdor system role defines, a command the bot does not have, and a capability that is neither the engine’s nor inconstitution/project-capabilities.json(with a did-you-mean). - So is a group rule without
role, and adefaultwith one. - A
@usernamewhere an id belongs is refused, with the way out: ask the agent, becauseaccess_updateresolves a@usernamethrough the chat log and then your Telegram account. A person can also get their own id from@userinfobot. Usernames can be re-registered by someone else, so access is never keyed by one. - A file that does not validate fails the boot.
- An edit made at runtime that does not validate is logged and refused, and the last valid version stays in force.
- A file that no longer validates is repaired by saving the whole file
(
access_updatewithfile, or the dashboard’s “Fix access.yaml”). The new file is then measured against an empty one, so everything it opens counts as widening and goes throughaccess_widen, or your yes on the page.
The code tier — bots[].policy
When the fields cannot say it — a subscription table, a schedule, a title
pattern, a moderator check — write it as a function. Put the functions in a
module under features/, import it in omnislash.config.ts, and attach it
per bot:
// features/support/chat-policy.ts
import { defineChatPolicy } from '@omnislash-ai/config/public';
export const supportPolicy = defineChatPolicy({
chat(c, fromFile) { /* → Partial<ChatRule> | undefined */ },
sender(s, fromFile) { /* → AccessTier | { tier, commands?, capabilities? } | undefined */ },
engage(s) { /* → boolean | EngagementDecision | undefined */ },
});
// omnislash.config.ts
import { supportPolicy } from './features/support/chat-policy.ts';
export default defineProject({
bots: [{ name: 'nova', role: 'nova', tokenEnv: 'TELEGRAM_BOT_TOKEN', policy: supportPolicy }],
});
| Function | Input | Returns | undefined |
|---|---|---|---|
chat(c, fromFile) | ChatSignals: surface, self, roster, scope, chat (tg:chat:<id>), chatTitle?. Never the sender, so the role cannot jump between people. fromFile is the rule the data tiers produced. | the fields to override, as Partial<ChatRule> | the data tiers stand |
sender(s, fromFile) | SenderSignals: the chat, plus sender { id, username?, name?, onBehalfOfChat? }, the resolved rule, and memberStatus() (the person’s status in THIS chat by the platform’s account, or undefined when the bot cannot see it; cached 5 minutes). fromFile is what identities + members decided. | a tier, or { tier, commands?, capabilities? }, where commands / capabilities replace the rule’s for this person | the data tiers stand |
engage(s) | EngagementSignals: everything above, plus tier and the message. Runs for member / owner only. | the engagement verdict — engagement.md | the rule’s engage |
Contract types live in @omnislash-ai/types/chat-policy (ChatPolicy,
ChatRule, ChatSignals, SenderSignals, EngagementSignals,
SenderVerdict, AccessTier, MemberStatus). defineChatPolicy and the
type re-exports are in @omnislash-ai/config/public.
Rules for the bodies:
- No side effects. The dashboard simulator and
access_explaincall your functions to explain a decision. - A throw closes the door. If
chat()orsender()throws, the message is dropped silently and the error is logged — nobody is admitted on a decision that could not be made, and no error reply lands in the room. The dashboard andaccess_overviewshow the error on that chat. - Read
envin the body only.omnislash.config.tsitself never importsenv; a module it imports may — engagement.md § Per-chat viaenv. - The result is data. The tier reaches the agent turn as
auth.access={ tier }, decided once at the gate; no closure rides a queue. A brain tool may read it from its execution context (context.auth.access?.tier). It is absent on every turn that did not come from a chat message (heartbeats, hooks, scheduled prompts, a Secretary owner’s own messages). Treat a missing tier as “not a chat person”, never as “owner”. The principal is stillauth.subjectId. Likeengagement.toolContext, it reaches the tools of API-model agents only; the MCP channel a CLI-model agent’s tools use does not carry it (engagement.md § Per-turn tool context). sender()cannot mint an owner. That tier is global; it lives underidentities.
What the bot does with an inbound message
message ─▶ rule = engine default ⊕ access.yaml ⊕ chat()
─▶ tier = owner | identities ⊕ members ⊕ sender() (a post as a chat: that chat's id)
blocked → dropped; not logged
stranger → no turn; a group line is logged only under context.from: all; a DM gets `decline`
member → admitted (only in a chat some role serves)
owner → admitted
─▶ a command? owner: any · member: only the rule's commands, else a private refusal
─▶ engage() ?? rule.engage
─▶ the member's turn budget (limit.turnsPerHour) ─▶ the agent turn, auth.access = { tier, capabilities }
- A chat no rule serves (a group without a role, or a DM whose rule names no role for a non-owner) starts no turn for anyone. An owner can still run commands there and answer cards, so a HITL or status group keeps working.
- The context block shows members’ and owners’ lines and names every
sender by numeric id. Under
context.from: membersa stranger’s line never reaches the agent, because a stranger’s text is a prompt-injection channel; the block’s header counts what it left out. The turn’s opening context line names the sender’s id and tier. - One turn, one person. When messages queue up behind a running turn, the inbox merges consecutive messages from one person under one access only. A different sender — or the same principal under another access, like you and your secretary’s counterpart — starts a new turn, so nobody’s text runs under someone else’s tier or ceiling.
- Business chats (secretary-mode.md) are outside
access.yamlfor admission. An owner connecting the bot is the grant — a connection by anyone who is not an owner is never served (checked on every message, so removing an owner stops their secretary at once). A counterpart is amemberof the role routed to them, with no commands and no turn budget — and capped like any member, at the bot’s DM ceiling (bots.<bot>.default⊕bots.<bot>.dmcapabilities; none unless you name some).
The member ceiling — capabilities
The role belongs to the chat, and the same role often serves you in one chat
and other people in another. capabilities on the rule is what a
non-owner’s turn may reach there, whatever the role holds:
a member's turn = (role grant ∪ live grants) ∩ rule.capabilities ('*' on the role becomes the ceiling)
an owner's turn = role grant ∪ live grants (never capped)
- None by default. The engine tier is
[], in a DM and in a group: a member talks to the role, and its tools stay out of reach until a rule names what they may use — the same fail-closed reading as “a group has no default role”. Name the capabilities a member needs on the rule (capabilities: [research:read]), or'*'to lift the ceiling (doctorChat accesswarns when that opens a'*'role to non-owners). - Capabilities, not tools. The same ids a role’s
capabilities:lists; every tool, harness builtin (a shell, file edits, the web) and prompt line derives from them. - Everything the turn does stays under it. The tools it is offered and may
call, the harness builtins, an agent it creates (
agent_create/agent_task— capped at the turn’s capped grant), a turn it hands to an existing agent (agent_call/agent_enqueue), a prompt it schedules (schedule_create_promptfires under the same ceiling), a HITL resume — and the memory the role would put in its prompt (MEMORY.md, periods, the recent window, recall): withoutmemory:readunder the ceiling a member’s turn reads no memory, through a tool or through its prompt. - A grant cannot lift it.
AskUserQuestionandhitl_request_grantstay available, but a grant request above the ceiling is refused before any card is raised, pointing ataccess_widen; a grant an owner approved for the agent widens your turns, never past a member’s ceiling. Raising the ceiling is anaccess.yamlchange, and adding a capability is a widening one. - Per person.
sender()may return{ tier: 'member', capabilities }— e.g. a group’s admins reachemail:send, the room does not. - Known gap. A Codex or OpenCode harness does not follow the grant on an unconfined turn yet: the engine’s own tools are capped there too, but its native builtins are not. Serve non-owners from a Claude or API role.
Buttons and approvals
Anyone in a group can press any button, so authorization is server-side only:
- Every button is owner-only, except an answer to a HITL question addressed to the person pressing. A member’s press gets a private “only an owner can use this button” and nothing runs.
- The
🤖 Agentskeyboard and/startdeep links are owner-only. In a group,/startnever shows a persistent keyboard. - A question stays where it was asked, and only its addressee (the
person the asking turn served) or an owner can answer it. An approval
or grant raised by a turn that serves a non-owner (a grant the group’s
agent asks for) goes to the HITL chat (the primary owner’s DM by default)
with a “raised by … in …” line. It never lands in a group where anyone
could approve it. A card from a run that served nobody (a cron-triggered
pipeline) stays in the chat the brain gave it (a scenario’s
chatbot.surface); there, too, only an owner can approve it. - Pending answers are keyed by chat and person: a reply-mode, an edit or a wizard step one person armed never consumes another person’s message.
Groups nobody configured
Every chat a bot is added to or removed from is recorded (my_chat_member),
along with the inviter, the title and the member count. A group the bot was
already in is recorded the first time it hears a message there. When the bot
arrives in a group no rule serves (the chat() code tier included), it
follows bots.<bot>.unknownChats:
-
ask(the default): a card lands in the HITL chat — “@x added nova to “Coffee” (12 members)” — with three buttons:- Serve… asks for the role (up to seven buttons — narrow roles first,
the bot’s own role last, roles with
'*'in red — or reply with any role id), then who may talk (everyone / people listed as members / only owners), and writes the rule. - Only me writes
{ role: <bots[].role>, members: owners }. - Leave makes the bot leave.
Nothing is written without an answer. The card is answered in Telegram. It closes by itself if the bot leaves the chat, or if the chat becomes served some other way (the dashboard’s Serve,
access_widen, a hand edit). An answer that serves the chat is one row in the audit log (access.widen, recorded against the chat, with the owner who answered). - Serve… asks for the role (up to seven buttons — narrow roles first,
the bot’s own role last, roles with
-
ignore: the bot stays, silent. -
leave: the bot leaves on its own.
Channels are recorded but never asked about or left.
When a group is upgraded to a supergroup (its id changes), the engine moves
its rule to the new id, along with every memberOf that names it and any open
card — one audit row (access.update, system work, against the new id), however many bots report
the move.
Widgets in a chat
A widget (an interactive page an agent opens — widgets.md)
reads this frame for who may use it: the person the opening turn served and
the owners, and — once widened to the member tier — everyone the chat’s rule
lets talk there. Strangers and blocked people never. A widget opened in a
group reaches people through a link to the bot; one who follows it into their
private chat with the bot is judged by the GROUP’s rule, not by the private
chat’s: a group member who never wrote to the bot still gets the page’s
button there (widgets.md § 9).
The same page link also opens in a plain browser after the person signs in
with Telegram: the sign-in proves who they are, and this frame and the
widget’s audience decide as they do inside Telegram. The chat rule’s
browser key narrows that: audience (the engine default) lets in whoever
Telegram would let in, owners only owners, off nobody — the page then
opens only inside Telegram. A business chat is off unless a tier (default
or dm) names it: signing in would show your client the login bot and your
domain, and their gestures run as you. Opening it further (off → owners →
audience) is a widening, so it goes through access_widen.
Command menus, /help, refusals
- Menus follow the rules. In every group, members see the group default
commands, and a served group shows its own
commands. The default DM menu shows the DM’s member commands only when the DM serves non-owners. An owner sees the full menu in their DM and in every served group. Menus resync at start and wheneveraccess.yamlchanges. A menu is cosmetic; the gate checks every command on its own. /helplists what the person may run where they are.- Refusals are private. A member who runs a closed command or presses someone else’s button gets an ephemeral message only they can see. If Telegram cannot deliver one, they get an alert on the press or a DM. The bot never refuses in public in a group.
Limits
limit.turnsPerHour (engine default 30, 0 = no limit) counts the turns
the bot actually takes for one member in one chat within a clock hour —
messages and /steer <text> alike.
Unaddressed chatter it ignores costs nothing, and so does a reply answering a
question or card the person may decide: it is never dropped. Over the budget the bot stays
silent and tells the person once an hour, privately. Owners are never
limited. Telegram itself caps a group at about 20 bot messages a minute, so
for a large group a low limit is a good idea; so is moving long conversations
to a DM.
Three surfaces
The agent — access_* tools + the chat-access skill
| Tool | Capability | What it does |
|---|---|---|
access_overview | access:read | Per bot (bot? narrows to one), every tier of its rules — defaults, the DM, each group it serves and each it only sits in — with the tier’s own entry, the rule in force and the tier behind each field; who may talk, when it answers, member commands, limits. Also: identities and owners; the roles a rule may name, with their grant and memory reach; bot health (privacy mode, can join groups, admin in N of M groups); warnings; and the file’s sha. A file that does not load comes back as state: 'invalid' with why. The DM row is one template for every DM: on a bot with policy.chat() it is marked dataOnly (the code decides each DM by who writes) — access_explain gives a person’s real answer. Each bot also carries policy — null for now: nothing on record says which module declared a bot’s chat policy (which functions it has is code) — and, for a Telegram Business secretary, its business state — whether an owner has it connected, and each business chat, named by its two sides — the account that connected the bot and the counterpart writing (a business chat is a private chat on that account, never a tg:chat id) — with the role the counterpart’s route names. |
access_identities | access:read | Three views (view.scope) — an identity is a person or a chat that speaks. No bot (scope: 'brain'): every identity the brain lists — the env owners and everyone under identities — with their brain-wide tier and where it is set (source: env · access.yaml). bot + chat (dm or a group; scope: 'chat'): who is in that chat — the group’s logged speakers, everyone identities lists and the owners, each with their tier there and the rule step that decided it. identity (tg:user:<id> or the numeric id, or a tg:chat:<negative id> that speaks; scope: 'identity'): its standing and its tier in every group a bot’s log heard it in and in each bot’s DMs (bot / chat narrow it). |
access_explain | access:read | Simulates one message (bot, chat: dm or a group, sender — an id or a @username — or senderChat, text?, addressed?, reply?, automaticForward?, status?) through the bot’s own resolver and your real policy code. The trace is ordered — setup (does the bot receive it at all: its profile, privacy mode, admin in the group, still in it), served (does a role serve the chat), rule (the tier behind each field), tier, engage, commands (what the sender may run there — a fact, never a no), limit (the member’s turns this hour) — and decided is the index of the first step that said no (the setup step when nothing did but the bot never reported — a bot that never started answers nobody; null when the bot answers). Also whether the sender is an owner and the turns they spent. Changes nothing. |
access_read_file | access:read | constitution/access.yaml whole, as written, with its sha (what access_update takes with file) and state: ok, invalid (with problems — the file still reads, so it can be fixed), absent (engine defaults everywhere). |
access_update | access:write | One write to the file, in one of three shapes: typed edits (identity, rule on default / dm / a group, remove_chat, unknown_chats), one tier replaced by YAML (rule: { bot, target, yaml }), or the whole file as text (comments kept as written). A narrowing or neutral change applies at once. A widening change is refused, with the list of what it would widen — whichever shape carried it. dryRun (required) — true previews, false commits; expectedSha refuses a stale write. Returns { path, sha, diff, consequences: { widening, changes } }. |
access_widen | access:widen (hitl: true) | The same write with widening allowed. The owner approves the exact change through the standard grant card. |
Every write by these tools is one row in the action log (audit_list),
against constitution/access.yaml; audit_list({ action: ['access.*'] })
reads every access change — these writes and the bot’s own (an admitted
group, a supergroup’s new id).
Two more reads answer “what happened in that chat”: chat_history
(runtime:read) is what one bot saw in a group and what it said there — the
chat log, newest first, threads included, each message with its author and
their tier when they spoke, q over the text, paged by cursor; DMs are not
logged (chat: 'dm' answers none). The log is a working set, reaped by age and
a per-chat cap — coverage.from is its oldest message still kept.
agent_list({ chat: { bot, chat } }) lists the agents of that chat: its chat
agent and the thread agents of its topics and reply threads (dm: every DM’s
chat agent).
What counts as widening:
- a person moves to a more permissive tier;
- a chat becomes served;
members,engage,commands,capabilities,limit,context.fromorbrowseropens up — on the DM, on a listed group, or indefault, which every group the file does not list runs as is (onepolicy.chat()serves, or one admitted later), even when each listed group overrides that field;- a served chat switches role, whatever its
members—policy.sender()may admit anyone even underowners; - a
blocked/strangerentry is dropped (the person may now be a member through a rule).
You can flip the approval on access:widen in
constitution/engine-capabilities.json
(contract.md § 7).
The engine skill chat-access (in skills/engine/) teaches the agent this
model. Fields first, chat-policy.ts only when logic is needed. It must
never open a role with '*' (or one that recalls your memory lines) to
non-owners without your explicit yes to exactly that.
The dashboard — Bots (/chats)
- Header: each bot’s health from
getMe()and its chat registry — privacy mode, whether it can join groups, admin in N of M groups. Every warning is a sentence plus the action that fixes it (“make the bot an admin there — no rights needed”). - Rail: every bot’s DM, default and groups, with who may talk in words, plus Seen, not served (each group with Serve).
- Rule tab: the rule as a form. Every field is labelled with the tier its value comes from (engine default · bot default · this chat · code), and a role’s capabilities and memory reach are shown before you save.
- Identities tab: who is in the chat (from its log plus
identities), with each person’s tier editable inline. - Simulate tab: a person, a text,
addressed/reply, and a status stub in, the full trace out — computed by the same resolver and your real functions. - Raw tab: the YAML of that one tier.
- Identities (
/chats/identities, beside Bots in the page’s tab bar): everyone the brain names — the env owners andidentities— with the tier that holds in every chat of every bot and where it is set. History (/chats/history): the commits ofaccess.yaml.
The page binds the same access.* ops the agent’s tools are: the overview,
the identities, the simulator and every write — a form’s edit, the Raw tab, the
whole-file editor — run the engine’s code, by compare-and-swap, and land in
the action log like an agent’s. A save goes through access.update; when it
would widen access, the page lists what it opens and writes it through
access.widen only on your yes — no grant card, since whoever holds the
dashboard login is already trusted with the brain. “Ask about this chat” on the page hands the
chief the bot and chat as refs; it reads them with access_overview / access_explain.
Telegram — cards instead of config
The unknown-group card above, private refusals, menus built from the rules.
Recipes
1. A support group — everyone talks to a separate, narrow role
bots:
nova:
chats:
"tg:chat:-1001234567890": { title: Support, role: support-helper, members: everyone, capabilities: [memory:read], limit: { turnsPerHour: 10 } }
# features/support/support-helper.agent.md — frontmatter
capabilities: [memory:read]
memory: { write: support, read: [], recall: [content/support/faq] }
2. DMs only for members of that group (the bot must be an admin there)
dm: { members: { memberOf: ["tg:chat:-1001234567890"] }, role: support-helper, decline: Only for support group members. }
3. Answers everything in a group, but stays silent at night
"tg:chat:-1009876543210": { role: team-ops, members: everyone, engage: always }
engage(s) { const h = new Date().getHours(); if (h < 8 || h > 22) return false; } // undefined → rule.engage
4. A paid subscription from your own table
async sender(s, fromFile) {
if (fromFile === 'stranger' && s.scope === 'dm' && (await isSubscriber(s.sender.id))) return 'member';
}
The DM rule must name a role, or a member there has nothing to talk to.
5. Group admins get moderator commands
async sender(s, fromFile) {
if (fromFile !== 'member' || s.scope !== 'group') return;
const st = await s.memberStatus();
if (st === 'creator' || st === 'admin') return { tier: 'member', commands: [...s.rule.commands, 'stop', 'clear'] };
}
6. Every client group by title pattern, without listing them
chat(c, fromFile) {
if (!fromFile.role && c.chatTitle?.startsWith('[Client]')) return { role: 'client-support', members: 'everyone' };
}
7. Two bots in one group — the one addressed answers, otherwise they take
turns. Use engage() with s.self / s.roster; see engagement.md
use case 4.
8. A journal — the bot reacts to its own channel’s auto-forwards
"tg:chat:-1005555555555": { role: nova, members: owners, engage: never }
engage(s) { if (s.automaticForward && s.senderChat?.id === JOURNAL_CHANNEL) return { engage: true, steer: '…' }; }
Close a chat: access_update with { op: remove_chat } (or set
members: owners). Block a person everywhere:
{ op: identity, tier: blocked }. Both apply at once.
Checking
- “Who can use the bot?” →
access_overview, or the dashboard rail. - “Why didn’t the bot answer Alex?” →
access_explain, or the Simulate tab. - Doctor
Chat accessfails a file the bot would refuse. It warns when:- a role with
'*'is open to non-owners with the ceiling lifted (capabilities: ['*']), or a role that reaches your memory lines is open to them under a ceiling that holdsmemory:read; memberOfnames a chat where the bot is not an admin;engage: alwayssits in a group the bot hears only when addressed (privacy mode on, not an admin);- a group’s
contexts:names a feature context that does not exist, or an entity key that does not fit it (missing on a keyed context, present on one that names no entity).
- a role with
Moving from the old keys
| Was | Now |
|---|---|
TELEGRAM_GUEST_IDS=… | identities: { "tg:user:<id>": { tier: member } } plus a rule that admits listed people (members: listed) and names a role. A set TELEGRAM_GUEST_IDS fails the boot. |
TELEGRAM_ALLOWED_IDS=@username | numeric user ids only |
bots[].engagement: fn / defineEngagementPolicy(fn) | bots[].policy: defineChatPolicy({ engage: fn }). The verdict is the same; the signals add chat, sender, tier, rule. |
bots[].engaged: { agent, context } | constitution/access.yaml → bots.<bot>.default: { agent, context } |
| a group the bot was added to ran the bot’s role | a group is served only by the role its own rule names |
| a chat agent’s row kept the role it was created with | every chat agent follows its chat’s role on each message; your own DM keeps its /model tuning and follows only a role switch. In any chat but your own DM, /agents can switch only to an agent running the chat’s role; one with another role is switched back to the chat’s own agent on the next message |
The old keys fail the load, and the error message names where each one moved.
EngagementSignals / EngagementPolicy are imported from
@omnislash-ai/types/chat-policy.
Out of scope today
- An agent per member inside a group. A group has one agent per bot, and
the conversation is public. For a private session, send the person to a DM
(
?start=). - A memory line per person. Lines separate roles (memory.md). Several DM members of one role share its line; a person who needs isolation gets their own role.
- Per-person OAuth (a member’s own Gmail). Integration credentials are brain-wide, which is why member roles should not get those tools.
- Join requests and
chat_membertracking.memberStatus()on demand covers the membership cases. - A visual editor for the code tier — deliberately never. The form edits data, the simulator explains code, and the agent writes both.