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.yaml yourself.

If the fields there cannot express what you want, write a function in the bot’s policy.

Remember three things:

  1. 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.
  2. Each person gets a tier: owner (you), member, stranger or blocked.
  3. 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 owner tier. 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, the owner notify 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:

QuestionThe answerData — constitution/access.yamlCode — bots[].policy
Which role serves this chat?persona, tools, memory line, modelthe chat rule’s rolechat()
Who is this person here?a tieridentities + the rule’s memberssender()
When does the bot answer?engage / steer / agent scope / contextengage, agent, contextengage() — engagement.md
What may they run and reach besides talking?commands, and the capabilities their turn may reachcommands, capabilitiessender() → { 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

tierConversationCommandsCapabilities of their turnControl buttons (/agents, run / pipeline / edit cards)HITL approve / grantHITL question
owneryesallthe role’s, never cappedyesyesyes
memberyes, through engagethe rule’s commandsthe role’s, cut to the rule’s capabilitiesnonoonly the ones addressed to them
strangerno; their line reaches the context block only under context.from: allno—nonono
blockedno, and not even loggedno—nonono

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 identities entry with tier: 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.

  1. Anyone in TELEGRAM_ALLOWED_IDS is owner. Nothing below runs.
  2. identities:
    • owner and blocked apply in every chat of every bot;
    • member counts only where the rule admits more than owners;
    • stranger applies everywhere.
  3. The rule’s members:
    • everyone → member;
    • listed → only people marked member (step 2);
    • owners → nobody else;
    • { memberOf: [...] } → member if 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.
  4. sender(), if the bot has one. undefined defers. It cannot make anyone an owner (an owner verdict is clamped to member); grant that under identities.

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

FieldMeaningEngine default — DMEngine default — group
rolethe role serving the chat — its persona, tools, memory line, modelyour DM: bots[].role; anyone else: none (not served)none — a group is served only once its rule names a role
memberswho besides owners may talk: owners · listed · everyone · { memberOf: [tg:chat:…] }ownersowners
engagewhen the bot answers: addressed (an @mention, a reply to the bot) · always · neveralwaysaddressed
commandswhat a member may run (bare names, no /); owners run everythingstart, helphelp
capabilitiesthe 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 ceilingnone ([])none ([])
agentagent scope of an engaged turn: chat · threadby thread kind — engagement.md←
contextthe 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 }
limitturnsPerHour — turns the bot takes per member per hour; 0 = no limit3030
declinewhat a refused person is told in a DM; absent = silence——
browserwho 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 chataudience (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>.default applies to every chat of the bot, DMs included. It cannot name a role: a default role would leak into every group.
  • bots.<bot>.dm covers every DM of the bot. Its role serves the people it admits who are not owners; an owner’s own DM always runs bots[].role.
  • bots.<bot>.chats."tg:chat:<id>" covers one group and must name its role. A private chat id is refused here; DM access is configured under dm, and per person under identities.
  • Two keys belong to a group’s own entry only, never to default or dm: title (for people reading the file) and contexts — 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’s contexts: 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_explain shows both.

Validation — closed world, fail fast

Validation runs at boot and on every access_update:

  • A bots.<name> that is not one of your bots[] is an error.
  • So is a role no *.agent.md or system role defines, a command the bot does not have, and a capability that is neither the engine’s nor in constitution/project-capabilities.json (with a did-you-mean).
  • So is a group rule without role, and a default with one.
  • A @username where an id belongs is refused, with the way out: ask the agent, because access_update resolves a @username through 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_update with file, 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 through access_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 }],
});
FunctionInputReturnsundefined
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 personthe data tiers stand
engage(s)EngagementSignals: everything above, plus tier and the message. Runs for member / owner only.the engagement verdict — engagement.mdthe 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_explain call your functions to explain a decision.
  • A throw closes the door. If chat() or sender() 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 and access_overview show the error on that chat.
  • Read env in the body only. omnislash.config.ts itself never imports env; a module it imports may — engagement.md § Per-chat via env.
  • 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 still auth.subjectId. Like engagement.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 under identities.

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: members a 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.yaml for 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 a member of 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>.dm capabilities; 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 (doctor Chat access warns 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_prompt fires under the same ceiling), a HITL resume — and the memory the role would put in its prompt (MEMORY.md, periods, the recent window, recall): without memory:read under the ceiling a member’s turn reads no memory, through a tool or through its prompt.
  • A grant cannot lift it. AskUserQuestion and hitl_request_grant stay available, but a grant request above the ceiling is refused before any card is raised, pointing at access_widen; a grant an owner approved for the agent widens your turns, never past a member’s ceiling. Raising the ceiling is an access.yaml change, and adding a capability is a widening one.
  • Per person. sender() may return { tier: 'member', capabilities } — e.g. a group’s admins reach email: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 🤖 Agents keyboard and /start deep links are owner-only. In a group, /start never 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).

  • 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 whenever access.yaml changes. A menu is cosmetic; the gate checks every command on its own.
  • /help lists 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

ToolCapabilityWhat it does
access_overviewaccess:readPer 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_identitiesaccess:readThree 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_explainaccess:readSimulates 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_fileaccess:readconstitution/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_updateaccess:writeOne 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_widenaccess: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.from or browser opens up — on the DM, on a listed group, or in default, which every group the file does not list runs as is (one policy.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 under owners;
  • a blocked / stranger entry 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 and identities — with the tier that holds in every chat of every bot and where it is set. History (/chats/history): the commits of access.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 access fails 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 holds memory:read;
    • memberOf names a chat where the bot is not an admin;
    • engage: always sits 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).

Moving from the old keys

WasNow
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=@usernamenumeric 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 rolea group is served only by the role its own rule names
a chat agent’s row kept the role it was created withevery 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_member tracking. 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.

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