Console pages and layouts
Plugins can do more than send chat messages — they can contribute whole React pages to Remote Console. The Sandbox adapter's /sandbox chat page is one.
Like commands, pages live in a convention directory, pages/; files are discovered automatically and compiled into modules the browser can load.
Your first page
// pages/index/index.tsx
import { definePage } from '@zhin.js/console-contract';
export const meta = definePage({
title: 'My Plugin',
titleI18n: { 'zh-CN': '我的插件' },
icon: 'Box',
order: 10,
});
export default function MyPluginPage() {
return <div>Hello from my plugin</div>;
}- The default export is a React component — the page content.
- The named export
metadeclares title, icon and order. - Only
pages/<name>/index.tsx(orindex.ts) is a page; other files in the folder can be imported as components and helpers.
Start the bot, open Console, and the page appears in the navigation.
Page metadata
Every meta field is optional; definePage throws on unknown keys:
| Field | Default | Description |
|---|---|---|
title | Generated from the folder name (foo-bar → Foo Bar) | Navigation and header title |
titleI18n | none | Explicit titles for zh-CN / en, e.g. { en: 'My Plugin' }; missing entries retain title |
icon | none | A lucide-style icon name, e.g. 'Box', 'Workflow' |
order | 100 | Navigation order; lower comes first |
hideInNav | false | Hide from navigation |
requiredPermissions | [] | Permissions required to access, matched against the token's permissions |
requiredRoles | [] | Roles required to access, matched against the token's roles |
Navigation and the page list are filtered by the caller's token. The full token sees every page. Any other token sees only pages whose requirements its permissions and roles meet. Without a token, only pages with no requirements show. For token setup, see Console users and permissions.
Console selects titleI18n using its independent interface language. This only changes navigation and breadcrumb display, never routes, permissions or page content. Plugins localize their own components; third-party titles without translations remain unchanged.
Page addresses
Address = plugin path + page name:
| File | Owning plugin | Address |
|---|---|---|
pages/index/index.tsx | sandbox | /sandbox |
pages/workroom/index.tsx | your bot (root) | /p-workroom |
pages/index/index.tsx | your bot (root) | / |
index maps to the plugin path itself; other pages map to p-<name>. Two pages resolving to the same address fail at startup. Implementation: pageRoute in packages/console/plugin-contract/src/page.ts.
Custom navigation and footer
pages/ has two reserved names:
pages/nav/index.tsx: the navigation areapages/footer/index.tsx: the footer
Default-export a React component; no meta needed. When both .ts and .tsx exist for a slot, .tsx wins. Projects created by npm create zhin-app already include both as examples.
Example: the Sandbox chat page
The Sandbox adapter (plugins/adapters/sandbox) provides both an adapter and a page, so its package.json#zhin declares two Features:
{
"zhin": {
"protocol": 1,
"type": "plugin",
"entry": "./plugin.ts",
"runtime": "trusted",
"features": [
{ "package": "@zhin.js/adapter", "api": "^1.0.0" },
{ "package": "@zhin.js/page", "api": "^1.0.0" }
]
}
}@zhin.js/adapterdiscovers the adapter underadapters/, providing the WebSocket/sandboxconnection.@zhin.js/pagediscoverspages/index/index.tsx, which is why Console shows a/sandboxchat page.
The page's SandboxChat component connects to /sandbox over WebSocket (address and token in pages/sandboxTransport.ts). Sending a message in the page is equivalent to a real platform receiving one: it goes through middleware, command matching and the AI. So you can fully debug commands, component rendering and the Agent without any real platform. Sandbox displays outbound HTML directly instead of converting it to an image or text — see Middleware & components.
The plugin entry itself needs nothing; the page and adapter are discovered automatically:
// plugins/adapters/sandbox/plugin.ts
import { definePlugin } from 'zhin.js';
export default definePlugin({
name: 'sandbox',
metadata: { displayName: 'Sandbox Adapter' },
});Page data: plugin RPC
When a page needs to show a plugin's runtime status or offer a few buttons, the plugin can register its own Console RPC instead of opening a WebSocket or an HTTP route.
In setup, the plugin registers a read-only status and, if needed, a few limited actions:
// plugin.ts
import { definePlugin } from 'zhin.js';
import { definePluginConsoleRpc, PluginConsoleRpcError, pluginConsoleRpcToken } from 'zhin.js/plugin-runtime';
export default definePlugin({
name: 'ambient',
setup(context) {
context.resources.provide(pluginConsoleRpcToken, definePluginConsoleRpc({
status: {
page: 'index', // same access rules as pages/index
handler: async () => ({
mode: 'shadow',
recent: recentJudgments(10).map(j => ({ at: j.at, spoke: j.engaged })),
}),
},
actions: {
'clear-cooldown': {
page: 'index',
handler: async ({ group }) => {
if (typeof group !== 'string') throw new PluginConsoleRpcError('invalid_input', 'Pick a group');
return { cleared: clearCooldown(group) };
},
},
},
}));
},
});Pages call it with pluginRpc from @zhin.js/client. pluginId is the plugin's PluginId (for example root/ambient), the same value as owner in the page list:
import { pluginRpc } from '@zhin.js/client';
const status = await pluginRpc<{ mode: string }>('root/ambient', 'status');
await pluginRpc('root/ambient', 'clear-cooldown', { group: '123' });Requests go through the existing POST /api/console/request, with the type plugin.<pluginId>.status or plugin.<pluginId>.<action>. Action names may use only lowercase letters, digits and -, and status is reserved.
Besides status, you can register a few read-only queries under queries. They follow the same rules as status (page, demo and requiredPermissions all work). A query and an action can't share a name:
definePluginConsoleRpc({
queries: {
history: { page: 'index', handler: async () => ({ days: last7Days() }) },
},
});Access rules:
- Follows the page: with
pageset, the caller must meet that page'srequiredPermissionsandrequiredRoles, plus any the method declares itself. If the page isn't loaded in the current generation, the call is refused. - The full token can call every method.
- Any other token (
scope: user/demo) can readstatusif it holds every required permission and role. Astatuswith no requirements at all is open to these tokens only when it setsdemo: true. - Actions: other tokens can run an action only when the action itself declares
requiredPermissions,requiredRolesoranyOfPermissionsand the token meets all of them, including the page's. Otherwise the call is refused. An action with no requirements of its own needs the full token; a page's requirements never open an action on their own. - Any one of: holding one of the permissions in
anyOfPermissionsis enough (on top ofrequiredPermissions). Use it when a new permission sits beside an older one, for example approval cards acceptops:approveor the oldertool_approvals:resolve.
actions: {
'clear-cooldown': {
page: 'index', // the page's requirements apply first
requiredPermissions: ['ambient:write'], // then this one lets ordinary users run it
handler: async ({ group }) => ({ cleared: clearCooldown(String(group)) }),
},
},Ordinary users see what you return, so:
- Return only the few fields the page needs. Don't return log records, prompts, chat text or secrets.
- The result must be JSON and at most 256 KB. The request's
datamust be a JSON object of at most 64 KB. - A handler that doesn't return within 10 seconds times out, and its
signalis aborted. - To show users why something failed, throw
PluginConsoleRpcError. Its message is shown as is. Any other error shows only a generic failure message.
Plugin RPC doesn't change configuration. Use config:patch for that.
Built-in status: ambient engagement and skills assistant
Ambient engagement (群聊插话) and the skills assistant (技能助手) are provided by the Host itself, not by a plugin, so they sit under the root plugin:
| Method | Call | Permission needed |
|---|---|---|
plugin.root.ambient | pluginRpc('root', 'ambient') | ambient:read |
plugin.root.skills | pluginRpc('root', 'skills') | skills:read |
The full token doesn't need these permissions. Pages list the same permission in requiredPermissions, and tokens get it in http.tokens[].permissions (see Users and permissions). Both queries are read-only and take no input.
The data lives only in memory: the last 24 hours, at most 2000 entries, cleared on restart. It never includes chat text, prompts, judge reasons or secrets. All times are millisecond timestamps.
plugin.root.ambient returns:
interface AmbientStatus {
enabled: boolean; // at least one group is configured
mode: 'off' | 'shadow' | 'active'; // shadow: judge without speaking
groups: number | 'all'; // configured groups, or 'all' for '*'
judges: string[]; // judge models in order (provider/model)
last24h: { spoke: number; watched: number; stayed_quiet: number; skipped: number };
decisionShadow: { agreed: number; disagreed: number }; // ai.decisions.ambient shadow vs the chat-model judge
recent: Array<{ // newest first, at most 20
at: number;
group: string; // platform:groupId
outcome: 'spoke' | 'watched' | 'stayed_quiet' | 'skipped';
skipReason?: 'cooldown' | 'hourly_cap' | 'judge_interval' | 'no_context' | 'judge_failed';
confidence?: number; // 0..1, two decimals
judge?: 'decision' | 'chat-model';
}>;
updatedAt: number | null;
}watched means the judge would have spoken but shadow mode kept it quiet.
plugin.root.skills returns call statistics for structured decisions (choosing skills, tools, memory, sub-agents, approvals and ambient engagement):
interface SkillsStatus {
tasks: Array<{ // one row per task, sorted by name
task: 'skills' | 'tools' | 'memory' | 'agents' | 'approval' | 'ambient';
mode: 'off' | 'shadow' | 'active'; // mode of the latest call
calls: number; selected: number; abstained: number; shadow: number; failed: number;
averageMs: number;
inputTokens: number; outputTokens: number;
lastModel?: string;
lastAt: number;
}>;
totals: { calls: number; inputTokens: number; outputTokens: number };
recent: Array<{ // newest first, at most 20
at: number; task: string; mode: string;
outcome: 'selected' | 'abstained' | 'failed' | 'shadow';
durationMs: number;
model?: string;
reason?: string; // machine-readable failure/abstain reason, never free text
}>;
updatedAt: number | null;
}Built-in queries: decision replay
The decision replay on the Bot status (机器人状态) page (ADR 0036 slice C) is also provided by the Host. It reads the ai_decision_events decision audit table: one row each time the bot decides whether to call the model, whether to speak, or whether to call a tool. Rows from one turn share a turn_id.
| Method | Call | Permission needed |
|---|---|---|
plugin.root.decisions | pluginRpc('root', 'decisions', { ... }) | decisions:read |
plugin.root.decision-turn | pluginRpc('root', 'decision-turn', { turnId }) | decisions:read |
plugin.root.decisions-export | pluginRpc('root', 'decisions-export', { since, until, ... }) | decisions:read |
All three are read-only. Times are millisecond timestamps. kind and outcome only accept the ADR 0036 enums; a typo returns invalid_input rather than an empty result.
decisions: the list, newest first. Optional input:platform,sceneId,kind,outcome,reasonCode(e.g.cold_field_mention_only_on, for rows that share a kind),since(inclusive),before(exclusive),limit(default 50, at most 200). Returns{ persistent, items, nextBefore }. WhennextBeforeisn'tnull, pass it back asbeforefor the next page.decision-turn: one turn's timeline, oldest first. Returns{ turnId, startedAt, endedAt, truncated, events }. A turn with no rows (or past retention) returnsnot_found.decisions-export: exports a time window.sinceanduntilare required (both inclusive, at most 31 days), and the list filters can be added. Pages come back oldest first as{ table, exportedAt, window, filters, count, nextSince, events }. WhennextSinceisn'tnull, pass it back assince; the pages'eventsjoined in order are the full export.
Each row uses the table's column names (id, ts, turn_id, instance_id, platform, scene_id, user_id, kind, outcome, reason_code, reason_detail, payload, actor, actor_id), with payload as an object. Rows are redacted when written: no secrets, cookies, tokens or full prompts.
A single result is capped at 256 KB, so the list and export are paged by size. Rows from the same millisecond always land on the same page, so paging by timestamp never skips or repeats a row. Without a database (persistent: false) only the last 200 in-process rows are visible.
Rows are kept for 14 days by default; change it with ai.audit.retentionDays. Expired rows are removed at startup and then hourly. For the switches, see the control plane below.
Built-in actions: control plane
The control plane on the Bot status (机器人状态) page (ADR 0036 slice D) is also provided by the Host. It is the one place to make the bot go quiet right away.
| Method | Call | Permission needed |
|---|---|---|
plugin.root.control | pluginRpc('root', 'control') | control:read |
plugin.root.control-set | pluginRpc('root', 'control-set', { expectedRevision, instance?, sessions? }) | control:write (an action; the admin token can always call it) |
Four switches:
| Switch | Effect |
|---|---|
emergency_stop | No model calls and no outbound messages (including proactive ones such as scheduled notices); only the audit and Console reads remain |
muted | Doesn't speak: incoming messages, ambient engagement and scene event wakes never reach the model |
read_only | No writes: no model calls, no outbound messages |
mention_only | Only messages that @ the bot (match the trigger) or private chats reach the model; ambient engagement and scene event wakes are skipped |
There are two levels: the instance default (instance) and per-session overrides (sessions, by platform + sceneId; sceneId looks like group:123, the same key as the session profile). An override can only be stricter: false in a session just removes that override and never relaxes an instance switch. The order is emergency_stop → instance muted → session muted → read_only → mention_only → normal speak / ambient / scene event policy, and the first match wins. The control plane runs before the session profile's can-speak check, so a blocked message skips that check and ambient judging. If a switch changes while a turn is running, the bot checks again before sending, and any of the first three switches stops the message.
control: returns{ revision, instance, sessions, temporary, updatedAt, updatedBy, persistent, available, precedence }.temporary[]holds overrides added by policy (for now only the cold-fieldmention_only, see below); each hasplatform,sceneId,switch,source(cold_field),actor(policy),sinceandexpiresAt. It doesn't count towardsrevision.available: falsemeans the saved state can't be read right now. Until it can, every check acts asemergency_stop(fail closed) and writes are refused.control-set: changes only the switches you send.expectedRevisionis required (read it withcontrolfirst); if it doesn't match you getconflictand nothing is overwritten. On success it returns the new state andchanged(how many scopes actually changed). Sendingmention_only: falseexplicitly (per session clears that session, at instance level clears all) also clears cold-field temporary overrides, writing onecontrol_change/cold_field_mention_only_clearedrow (actor=operator) per session.
Each block writes a kind=control_gate row (reason_code is control_emergency_stop, control_muted_instance, control_muted_session, control_read_only or control_mention_only). Each change writes one kind=control_change row per changed scope with actor=operator and the before and after values in payload; both show up in the decision replay. The switches are saved in the ai_control_state table and survive a restart; without a database they live only in the process.
The model has no tool that can read or change these switches: it can't unmute itself or turn off emergency_stop. The dangerous-tool hard gate is slice E and isn't covered here.
Built-in operations: welcome and leave records
The per-session ops config (ADR 0037 phase 1, slice A) is also served by the Host. The Console page comes later; only the RPC exists for now.
| Method | Call | Permission |
|---|---|---|
plugin.root.session-ops | pluginRpc('root', 'session-ops') | control:read |
plugin.root.session-ops-preview | pluginRpc('root', 'session-ops-preview', { platform, sceneId, template? }) | control:read |
plugin.root.session-ops-set | pluginRpc('root', 'session-ops-set', { expectedRevision, defaults?, sessions? }) | control:write (an action; the admin token can always call it) |
The config is keyed by platform + sceneId (like group:123, the same key as the session profile) and is stored apart from the session profile:
| Field | Meaning | Default |
|---|---|---|
welcome.enabled | Send a welcome (per session only) | false |
welcome.template | Template; variables {members} (mentions each one), {nickname}, {group}, {rules_url}; unknown variables are kept as written | 欢迎 {members} 加入{group}! |
welcome.mergeWindowMs | Joins inside this window share one welcome; 0 turns merging off | 60000 |
welcome.maxPerHour | Most welcomes per session per hour | 10 |
welcome.rulesUrl | Value of {rules_url} | empty |
leave.record | Record leaves in the audit log | true |
leave.notify | Post a message when someone leaves (stored only for now, nothing is sent) | false |
coldField.enabled | Cold field (slice C): turn it on (per session only) | false |
coldField.threshold | How many group messages in a row, not addressed and not engaged by ambient, before a temporary mention_only (1–1000) | 20 |
coldField.durationMs | How long the temporary mention_only lasts (1 minute – 24 hours) | 1800000 |
ops.cooldownMs | Group-ops cooldown (slice E): one hard-gate approval card per platform + sceneId + action (the tool's local name, e.g. kick_member) within this time; 0 turns it off, max 24 hours. Settable as instance default and per session | 60000 |
session-ops: returns{ revision, defaults, sessions, updatedAt, updatedBy, persistent, available }; each session hasoverrides(fields set explicitly) andeffective(merged with the instance defaults).session-ops-preview: renders only. It doesn't send and doesn't write to the audit log. It uses the last real join seen in that session (in memory) or a sample member, and returns{ text, parts, unknownVars, sample, members, enabled }. Passtemplateto preview a template you haven't saved.session-ops-set:defaultstakes only the template and numbers (no switches). Insessions[], the fields you send replace the old values,nullfalls back to the instance default, andreset: trueremoves the session's config.expectedRevisionis required; a mismatch returnsconflict. Every scope that actually changed writes onekind=config_change,actor=operatorrow.
Group-ops cooldown (ADR 0037 §3, slice E): before the hard gate creates a new approval card, it checks the cooldown. The timer starts when the card is created, whether it is later approved, denied or times out. Calling the same action again during the cooldown is refused without a new card; the model is told it is cooling down and how many milliseconds remain. That writes kind=ops_cooldown, outcome=deny, reason_code=ops_cooldown_hit (payload has action, target, remainingMs, platform, sceneId) plus a tool_hard_gate deny row with the same turn_id. Outside a cooldown it writes ops_cooldown_passed and goes on to approval. A card an operator already approved in the Console still runs for the same arguments (that card started the timer). The timers live in process memory and reset on restart; the cooldown only throttles cards, dangerous tools still need a human approval every time.
Cold field (ADR 0037 §1.3, slice C): only group messages count. An unaddressed group message that went through ambient judging and wasn't engaged (including ambient's own cooldown / cap skips) adds 1. Being addressed, an ambient engagement or the bot speaking on its own (a scene event wake reply, a template welcome, an announcement) resets it. Messages with ambient off, or blocked earlier by the control plane or the session profile, neither count nor reset. At coldField.threshold the session gets a temporary mention_only (actor=policy, expiresAt = now + durationMs), written as kind=control_change, reason_code=cold_field_mention_only_on; it is removed automatically when it expires, writing cold_field_mention_only_expired. Temporary overrides are stored apart from the operator's switches, only ever make things stricter, never change a manual switch, and show up in control's temporary[]. While one is active, skipped entries still write control_gate with reason_detail policy:mention_only. Counters and temporary overrides live in process memory and reset on restart. A session that is already mention_only doesn't get another one.
A welcome goes: join → merge window → hourly cap → control plane (a template welcome counts as addressed: mention_only, including the cold-field override, doesn't block it; the stop switches still do) → session-profile speak check → send. A blocked welcome is only audited and never sent later; a failed send isn't retried. Once welcome.enabled is on for a session, scene event wakes no longer run the model for joins, so the template welcome isn't doubled. Rows use kind=member_lifecycle with welcome_sent / welcome_merged / welcome_rate_limited / welcome_disabled / member_leave_recorded; blocks by the control plane or the session profile keep their control_* / cannot_speak_* codes. The config lives in the ai_session_ops table; if it can't be read, no welcome is sent and leaves are still recorded.
Built-in: announcements (draft → approve → send)
Announcements (ADR 0037 phase 1, slice B) are Host RPCs; the Console page comes later. Reads need control:read; create, edit and submit need control:write; approve, reject and resend need the approver permission ops:approve (slice E), so control:write alone cannot approve. ops:approve also lists and settles dangerous-tool approval cards (see Dangerous-tool approval cards).
| Method | Call | Permission |
|---|---|---|
plugin.root.announcements | pluginRpc('root', 'announcements', { platform, sceneId, status? }) | control:read |
plugin.root.announcement | pluginRpc('root', 'announcement', { id }) | control:read |
plugin.root.announcement-create | pluginRpc('root', 'announcement-create', { platform, sceneId, content, endpoint? }) | control:write (action) |
plugin.root.announcement-edit | pluginRpc('root', 'announcement-edit', { id, expectedRevision, content }) | control:write (action) |
plugin.root.announcement-submit | pluginRpc('root', 'announcement-submit', { id, expectedRevision }) | control:write (action) |
plugin.root.announcement-approve | pluginRpc('root', 'announcement-approve', { id, expectedRevision }) | ops:approve (action) |
plugin.root.announcement-reject | pluginRpc('root', 'announcement-reject', { id, expectedRevision, reason? }) | ops:approve (action) |
plugin.root.announcement-resend | pluginRpc('root', 'announcement-resend', { id, expectedRevision }) | ops:approve (action) |
Status only moves forward: draft → pending → approved → sent, with rejected / expired / failed on the side. Editing the text (in any non-final status) goes back to draft and recomputes contentHash; a pending announcement expires after 24 hours. The model can only create drafts with the announcement_draft tool (risk: safe); it can't submit, approve, or send.
announcement-approvesends right away and returns the status after sending plussend.result(sent/failed). Order: hash check → control plane (judged as addressed, somention_onlydoesn't block) → session-profile speak check → hard gate (this approval counts as the hard-gate approval, no second approval card) → the platform's group-notice API.- A blocked or failed send becomes
failedwith a reason. It isn't queued or retried;announcement-resendputs it back topendingand it needs another approval. If the text no longer matches the approved hash, it's refused (announcement_hash_mismatch). - Every action needs
expectedRevision; a mismatch returnsconflictand nothing is overwritten. Each transition writes onekind=announcementrow (announcement_drafted/_submitted/_approved/_rejected/_expired/_sent/_send_failed/_resend/_hash_mismatch); blocks by the control plane or session profile keep theircontrol_*/cannot_speak_*codes. Data lives in theai_announcementstable.
Built-in: session events (reminders)
Session events (ADR 0037 phase 1 slice D) have Host RPCs; the Console page is built separately. Reads need control:read; create, update, and cancel need control:write.
| Method | Call | Permission needed |
|---|---|---|
plugin.root.session-events | pluginRpc('root', 'session-events', { platform, sceneId, status? }) | control:read |
plugin.root.session-event | pluginRpc('root', 'session-event', { id }) | control:read |
plugin.root.session-event-create | pluginRpc('root', 'session-event-create', { platform, sceneId, title, at, remindBefore?, template?, endpoint? }) | control:write (action) |
plugin.root.session-event-update | pluginRpc('root', 'session-event-update', { id, expectedRevision, title?, at?, remindBefore?, template? }) | control:write (action) |
plugin.root.session-event-cancel | pluginRpc('root', 'session-event-cancel', { id, expectedRevision }) | control:write (action) |
atis a millisecond timestamp (stored in UTC;{at}is shown in the instance time zoneTZ, defaultAsia/Shanghai).remindBeforeis an array of lead times in milliseconds, at most 5, each up to 30 days. There's always a reminder atatitself;remindBeforeonly adds earlier ones. Lead times that are already in the past when you create or update the event count as fired.titleis at most 60 characters;templatecan use{title}{at}{in}, and an empty one uses the default【日程提醒】{title}:{at}({in}).- Status:
scheduled→done(every reminder fired) /cancelled. Updating and cancelling only happen here, including events the model created; the model can only create events for the current group or channel with thesession_event_createtool (risk: safe). - When a reminder is due, reminders for the same session in the same minute become one message. Order:
inbound_wake(schedule_wake_fired) → control plane (judged as addressed, somention_only, including the cold-field override, doesn't block; the stop switches still do) → session-profile speak check → template sent as is (no model call). A blocked or failed reminder still counts as fired: it isn't resent or retried. - After a restart, reminders missed by more than 10 minutes aren't sent and are logged as
schedule_wake_missed; ones within 10 minutes are sent. - Every write needs
expectedRevision; a mismatch returnsconflictand nothing is overwritten. Audit rows usekind=schedule:schedule_event_created/_updated/_cancelled,schedule_wake_fired(sent alone) /schedule_wake_merged(sent merged) /schedule_wake_missed; blocks by the control plane or session profile keep theircontrol_*/cannot_speak_*codes. Data lives in theai_session_eventstable (notschedule-jobs.json).
Built-in actions: group moderation rules (draft → approve in group → active)
Group moderation rules (ADR 0039 slice B) are an "approve once" violation ladder, such as "remind advertisers first, then mute for 10 minutes". Reads need control:read, drafting / disabling need control:write, and approving / rejecting need ops:approve.
| Method | Call | Permission |
|---|---|---|
plugin.root.group-rules | pluginRpc('root', 'group-rules', { platform, sceneId, status? }) | control:read |
plugin.root.group-rule | pluginRpc('root', 'group-rule', { id }) | control:read |
plugin.root.group-rule-create | pluginRpc('root', 'group-rule-create', { platform, sceneId, trigger, ladder, windowMs?, exempt?, content }) | control:write (action) |
plugin.root.group-rule-disable | pluginRpc('root', 'group-rule-disable', { id, expectedRevision, reason? }) | control:write (action) |
plugin.root.group-rule-approve | pluginRpc('root', 'group-rule-approve', { id, expectedRevision, contentHash? }) | ops:approve (action) |
plugin.root.group-rule-reject | pluginRpc('root', 'group-rule-reject', { id, expectedRevision, reason? }) | ops:approve (action) |
sceneIdmust begroup:<id>;triggerisadvertising/abuse/flood; eachladderstage is{ action: 'remind' | 'warn' | 'mute' | 'suggest_kick', durationSec? }, with mutes between 60 seconds and 24 hours;windowMs(the cool-down) defaults to 30 minutes.group-rule-createdrafts the rule and submits it right away (pending), posting a preview card to that group. The model only has therule_drafttool (risk: safe, group chats only): the person asking must be the group owner or an admin (judged from the live platform role on their message); otherwise it is refused andrule_draft_forbiddenis recorded.- Commands in the group (they only work in the rule's own group):
/rule approve <code>,/rule reject <code>,/rule disable <code|trigger>,/rules(lists the group's rules; anyone can read it). The code is the first 8 characters of thecontent_hashon the card; changing any part of the rule changes the code, so an old card can't approve new content. Approvers are the group owner / admins plusai.agent.toolHardGate.imApproval.approverIds; in the Console it isops:approve. - Status only moves forward:
draft→pending→active, withrejected/expired(not approved within 24 hours) /disabledon the side. A group has at most oneactiverule pertrigger; approving a new one disables the old one (rule_superseded). Disabling needs no approval; to relax a rule, disable it and draft a new one. - Each transition writes
kind=group_rule(rule_drafted/_submitted/_approved/_rejected/_expired/_disabled/_superseded/_hash_mismatch,rule_draft_forbidden). Data lives in theai_group_rulestable; final-state rules are deleted after 30 days or beyond 50 per group (pruned at startup and hourly). - Enforcement (slice C): when a detector reports a hit, the group's active rule runs its current stage and writes
kind=rule_enforcement:remind/warn: a template reminder that @-mentions the member (no model call), gated by the control plane (emergency_stop/muted/read_onlyblock it) and the session profile;rule_hit_remind/rule_hit_warn.mute: runs automatically, no card — the rule's approval is the hard-gate approval for this mute (thetool_approvalrow hasvia: rule,approvalId= the rule id,actor= the approver). It still passes the control plane'semergency_stop/read_onlyandops_cooldown;rule_hit_mute.suggest_kick: never kicks on its own. It raises amemberapproval card (with the evidence and the rule code) and kicks only once a human approves; repeat hits within the cool-down raise no new card;rule_hit_kick_suggested.- Owners / admins or whitelisted members:
rule_hit_exempt. The bot is not an admin:rule_hit_bot_not_admin, with one notice per group per day. - Ladder: a repeat within the quiet window moves up one stage; past the last stage, the last stage repeats; messages sent before the reminder was delivered do not count as repeats; an action that did not happen (blocked, cooling down, platform error) does not move the ladder. Progress is kept in memory only (capped, expired entries pruned) and resets on restart.
flood(repeat spam): detected locally, no model call. It fires when one member sends the same or a highly similar message 3 times within 60 seconds (several members each saying the line once does not count). Text is compared after stripping whitespace, punctuation and case; lines of 6+ characters also match at a character-bigram similarity ≥ 0.8; pure images / faces are compared by a content digest; reply quotes are ignored. A hit is handed to the group's activefloodrule and writes an extrarule_hit_floodrow (sameturn_idas the action row); with no active rule nothing happens. Detection lives in memory only (≤ 20 fingerprints per member, ≤ 5 000 tracked members, pruned after the window) and writes nothing to disk.advertising/abuse: judged by the content-moderation plugin (LLM review) and reported to the enforcer throughgroupRuleViolationToken(@zhin.js/core/runtime); the plugin no longer mutes or kicks by itself. Migration (a deliberate tightening): the first message in a group on the plugin'sgroupModerationlist creates onependingrule each for ads and abuse from the default template "remind → warn → mute 600 s, 30-minute quiet window" (drafted bycontent-moderation) and posts its preview card; auto-mute resumes only after an owner / admin confirms it once, and until then the plugin only reminds / warns. A group that already has a rule of that trigger in any state (including rejected / disabled) gets no new one. An ad posted within 10 minutes of joining now raises a kick approval card (referencing the active or pending advertising rule, without moving the ladder, audited withnewcomer: true) instead of an unattended kick.
Built-in actions: dangerous-tool approval cards
Calls stopped by the hard gate (ADR 0036 §3) leave an approval card here. The approver permission is ops:approve (ADR 0037 §3, slice E), separate from control:write. The older permissions still work so existing tokens keep working; holding either one is enough.
| Method | Call | Permission |
|---|---|---|
plugin.root.tool-approvals | pluginRpc('root', 'tool-approvals') | tool_approvals:read or ops:approve |
plugin.root.resolve-tool-approval | pluginRpc('root', 'resolve-tool-approval', { id, decision: 'approve' | 'deny' }) | ops:approve or tool_approvals:resolve (action; the admin token can always call it) |
Saving settings from a page (config:patch)
When a page changes a few settings, use config:patch to write exact paths instead of overwriting a whole section with config:set. Everything else in zhin.config.yml, including comments, is kept; the YAML/Config page stays available for advanced users.
const { revision } = await wsManager.getConfigSource();
const result = await wsManager.patchConfig({
instanceKey: 'typesafe', // omit to use the Host allowlist
expectedRevision: revision,
patches: [
{ op: 'set', path: ['ai', 'decisions', 'skills', 'mode'], value: 'shadow' },
{ op: 'remove', path: ['typesafe', 'timeoutMs'] },
],
});
if (result.status === 'saved' && result.restartRequired) showRestartHint(result.message);
if (result.status === 'conflict') showLatest(result.current, result.revision); // never overwrites someone else's change
if (result.status === 'invalid') showErrors(result.errors);Which paths can be written
| Call | Writable scope |
|---|---|
With instanceKey | The instance's own block [instanceKey, ...], plus Host sub-paths declared in the plugin schema.json root under x-zhin-config.patchPaths |
Without instanceKey | HOST_CONFIG_PATCH_PATHS (exported by @zhin.js/console-protocol; currently ai.trigger.ambient) |
Declaring cross-scope ownership in a plugin (the TypeSafe decision provider, for example):
{
"type": "object",
"x-zhin-config": { "patchPaths": ["ai.decisions"] },
"properties": { "...": {} }
}patchPaths may only name Host config paths at least two levels deep (such as ai.decisions). It cannot claim plugin, http, plugins, a whole Host key or another plugin's block. A path outside the scope is rejected and nothing is written.
Rules
expectedRevisionis required; read it fromconfig:get-source. If the file changed in the meantime the result isstatus: 'conflict'with the latestrevisionand the current value at each path; nothing is overwritten silently.- Every touched top-level section is validated against its schema before writing. Failures return
status: 'invalid'with full paths (e.g.$.ai.trigger.ambient.mode) and nothing is written. - On success
restartRequiredsays whether Host config changed: whentruethe file is saved but the process must restart for it to take effect, andmessagesays so. - A successful write publishes
config:updatedwithpaths(the paths written) and the newrevision. - Full-scope tokens only; not available on Demo deployments. At most 32 patches per call and 16 segments per path.
- Plugin status and actions use plugin RPC (
plugin.<PluginId>.status/plugin.<PluginId>.<action>); don't use it to write config.
How it works
Page files run in the browser, so Node doesn't load them; the client builder (TypeScriptClientBuilder, packages/console/pagemanager) bundles them with esbuild:
- Output: each page is bundled as
<owner>-<localName>-<contentHash>.jsinto the project's.zhin/client/and served byGET /assets/client/*(cache-control: immutable; new content means a new file name).@zhin.js/console-contractis replaced with a no-op stub at bundle time, andmetais extracted statically. - One React only: browsers don't understand bare imports like
import 'react', so imports on theALLOWED_ESM_CANONICALallowlist (react,react-dom,react-dom/client,react/jsx-runtime(-dev),react-router,react-router-dom) are rewritten to/esm/<enc>.mjs, built and proxied on demand by the Host so the whole Console shares one React instance. Anything off the allowlist returns 403. - Routes: registered in
basic/cli/src/plugin-runtime/console/host.ts.GET /consoleis the page index,GET /console/api/pagesreturns the page manifest, and other paths are matched to pages and served a page shell (with an importmap and mount script); no match returns 404, insufficient permission 403. The browser loads the page withimport(moduleUrl)and mounts the default export on#root.
Next steps
- Convention directories: where other capabilities go.
- Console: what Console itself can do.