Document the selective-mode gotcha for auto-created OneCLI agents (no secrets injected by default) with the CLI commands to inspect and fix it. Note that approval policies are not configurable via the SDK or `onecli@1.3.0` CLI — web UI only. Replace stale `NANOCLAW_ADMIN_USER_IDS` / `src/access.ts` references across CLAUDE.md, docs/architecture.md, docs/checklist.md, and docs/module-contract.md. Admin gating now runs host-side in src/command-gate.ts against `user_roles`; approver picks live in src/modules/approvals/primitive.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 KiB
Module Contract
This doc is the authoritative reference for how core and modules connect. Everything downstream — extraction PRs, install skills, module authors — keys off these signatures and defaults. See REFACTOR_PLAN.md for the broader plan; this doc is the narrow interface spec.
Principles
- Core runs standalone (modulo default modules — see tiers below). The optional-module portion of the
src/modules/index.tsbarrel can be empty and NanoClaw still routes messages in and delivers responses out. - Optional modules are independent. No optional module imports from another optional module. Cross-module coordination goes through a core registry (delivery action, response handler, etc.).
- Registries exist only when multiple modules plug into the same decision point. Single-consumer integrations use skill edits (
MODULE-HOOKmarkers) or stay inline withsqlite_masterguards. - Removing an optional module = delete files + remove barrel imports + revert any
MODULE-HOOKcontent. Migration files stay (data is preserved). Removing a default module is more invasive: it requires editing the core files that import from it.
Module taxonomy
Three categories. All three live under src/modules/ (or equivalent adapter dirs) with the same folder layout; the distinction is about shipping and who can depend on them.
1. Default modules
Ship with main in src/modules/. Imported by the default src/modules/index.ts barrel from day one. They are not really core — they live under src/modules/ specifically to signal "not really core, rippable if needed" — but they're always present on a main install. Core imports from them directly. No hook, no registry indirection for the exports themselves.
Current: typing, mount-security.
2. Optional modules
Live on the modules branch. Installed via /add-<name> skills that cherry-pick files. Register into core via one of the four registries (or MODULE-HOOK skill edits). Core and other optional modules must not statically import an optional module's code.
Current: interactive, approvals, scheduling, permissions. Pending: agent-to-agent.
3. Channel adapters
Live on the channels branch, installed via /add-<channel> skills. Not covered by this contract; they use the pre-existing ChannelAdapter interface and registerChannelAdapter().
Dependency rule
core ← default modules ← optional modules
- Core may import from core and from default modules.
- Default modules may import from core and from other default modules. They must not import from optional modules.
- Optional modules may import from core and from default modules. They must not import from each other.
Peer-to-peer coupling between optional modules goes through a core registry — see "The four registries" below. This keeps the module dependency graph a DAG and install order irrelevant.
Known transitional violations
src/access.ts(core) imports fromsrc/modules/permissions/(optional). Shim left from PR #5; resolved in the planned approvals re-tier (PR #7) which moves approver-picking into a new defaultapprovals-primitivemodule that may then depend on permissions however it likes — at which pointsrc/access.tsceases to exist.
The four registries
Each registry has an explicit default for when no module registers. Core must run when all four are empty.
1. Delivery action handlers
// src/delivery.ts
type ActionHandler = (
content: Record<string, unknown>,
session: Session,
inDb: Database.Database,
) => Promise<void>;
export function registerDeliveryAction(action: string, handler: ActionHandler): void;
Purpose: system-kind outbound messages (msg.kind === 'system') carry an action string. Core dispatches to the registered handler.
Default when action is unknown: log "Unknown system action" at warn and return. Message is still marked delivered (it was consumed by the host, not sent to a channel).
Current consumers: scheduling (5 actions — schedule_task, cancel_task, pause_task, resume_task, update_task), approvals (3 actions — install_packages, request_rebuild, add_mcp_server), agent-to-agent (create_agent, and the agent-routing branch keyed as a pseudo-action agent_route).
2. Router sender resolver + access gate
Two separate setters, called at different points in routeInbound. Preserves the pre-refactor ordering: sender-upsert side effects fire even when the message is ultimately dropped by wiring or trigger rules.
// src/router.ts
type SenderResolverFn = (event: InboundEvent) => string | null;
export function setSenderResolver(fn: SenderResolverFn): void;
type AccessGateResult =
| { allowed: true }
| { allowed: false; reason: string };
type AccessGateFn = (
event: InboundEvent,
userId: string | null,
mg: MessagingGroup,
agentGroupId: string,
) => AccessGateResult;
export function setAccessGate(fn: AccessGateFn): void;
Call order in routeInbound:
- Resolve messaging group.
- Sender resolver (if set). Permissions upserts the users row here so the record exists even if agent resolution drops the message.
- Resolve wired agents;
no_agent_wired→ record + drop. (Core writes the dropped_messages row.) - Pick agent by trigger rules;
no_trigger_match→ record + drop. - Access gate (if set). On refusal it writes its own
dropped_messagesrow keyed by policy reason.
Defaults when unset: resolver returns null; gate defaults to { allowed: true }. Every message routes through, no users table is needed, downstream tolerates userId=null.
Current consumer: permissions module (registers both).
Not registries, setters. There is one sender and one access decision per inbound message and one module that owns both. Calling setSenderResolver / setAccessGate twice overwrites; core does not iterate.
3. Response dispatcher
// src/index.ts (or src/response-dispatch.ts if it grows)
interface ResponsePayload {
questionId: string;
value: string;
userId: string | null;
channelType: string;
platformId: string;
threadId: string | null;
}
type ResponseHandler = (payload: ResponsePayload) => Promise<boolean>;
export function registerResponseHandler(handler: ResponseHandler): void;
Purpose: button-click / question responses arrive via the channel adapter's onAction callback. Core iterates registered handlers in registration order. The first one that returns true claims the response.
Default when empty: log "Unclaimed response" at warn and drop.
Current consumers: interactive (matches pending_questions), approvals (matches pending_approvals). The two tables have disjoint question_id / approval_id namespaces in practice (q-* vs appr-*), so first-match-wins is safe.
4. Container MCP tool self-registration
// container/agent-runner/src/mcp-tools/server.ts
export function registerTools(tools: McpToolDefinition[]): void;
Purpose: each tool module calls registerTools([...]) at import time. The MCP server uses whatever was registered.
Default: only mcp-tools/core.ts (send_message) registered.
Current consumers: all container-side modules (scheduling, interactive, agents, self-mod).
Skill edits to core
For one-off integrations with a single consumer, install skills edit core directly between MODULE-HOOK markers. No registry.
Marker format:
// MODULE-HOOK:<module>-<site>:start
// MODULE-HOOK:<module>-<site>:end
The skill inserts between markers on install and clears between them on uninstall. Markers live in core from day one (empty until a skill fills them).
Current uses:
src/host-sweep.ts→MODULE-HOOK:scheduling-recurrence— call to scheduling module'shandleRecurrence.container/agent-runner/src/poll-loop.ts→MODULE-HOOK:scheduling-pre-task— call to scheduling module'sapplyPreTaskScripts.
Promotion rule: if a third consumer appears for any marker, promote to a registry.
Guarded inline (core)
Some code stays in core but references module-owned tables. These use sqlite_master checks to degrade cleanly when the owning module isn't installed.
| Site | Owning module | Fallback |
|---|---|---|
container-runner.ts admin-ID query (user_roles, agent_group_members) |
permissions | returns [] |
container-runner.ts writeDestinations (agent_destinations) |
agent-to-agent | no-op |
delivery.ts channel-permission check (agent_destinations) |
agent-to-agent | permit (origin-chat always OK) |
delivery.ts createPendingQuestion (pending_questions) |
interactive | no-op (log warning) |
Container-side admin gating no longer exists. Admin authorization is now performed host-side in src/command-gate.ts, which queries user_roles directly — no env var is passed to the container, and no agent-runner fallback exists.
Migrations
All migrations live in src/db/migrations/ as TypeScript files exporting a Migration object:
export interface Migration {
version: number;
name: string;
up: (db: Database.Database) => void;
}
The barrel src/db/migrations/index.ts imports each and lists them in an ordered array.
Uniqueness key is name, not version. The migrator applies any migration whose name isn't in schema_version. Version stays as an ordering hint; integer collisions across modules are allowed.
Module migration naming:
- File:
src/db/migrations/module-<module>-<short>.ts Migration.name:'<module>-<short>'(e.g.'approvals-pending-approvals')
Uninstall behavior: migration files and barrel entries stay. Tables persist across reinstalls. No down migrations.
What a registry-based module provides
Each src/modules/<name>/ module must supply:
index.ts— imported bysrc/modules/index.tsfor side-effect registration (callsregisterDeliveryAction/setInboundGate/registerResponseHandlerat module load time).project.md— appended to projectCLAUDE.mdby the install skill. Describes module architecture for anyone reading the codebase.agent.md— appended togroups/global/CLAUDE.mdby the install skill. Describes the module's tools for the agent.- Migration file in
src/db/migrations/if the module owns any tables. - Barrel entry in
src/db/migrations/index.tsfor that migration.
Optionally:
- Container-side additions to
container/agent-runner/src/mcp-tools/<name>.tsthat callregisterTools([...]), with a barrel entry incontainer/agent-runner/src/mcp-tools/index.ts. MODULE-HOOKedits to specific core files, applied by the install skill.
What a module must not do
- Import from another module.
- Write to core-owned tables (
sessions,agent_groups,messaging_groups,schema_version, etc.) outside of migrations. - Depend on a specific channel adapter being installed.
- Break core behavior when unloaded. If a module's absence leaves a core feature non-functional, that feature belongs in core, not the module.