diff --git a/advanced/container-runtime.mdx b/advanced/container-runtime.mdx index 045e018..1b9e696 100644 --- a/advanced/container-runtime.mdx +++ b/advanced/container-runtime.mdx @@ -77,11 +77,90 @@ The agent container is built from `container/Dockerfile` and includes: ### Per-agent-group images -Agent groups can specify custom packages in `container.json`. The host builds a derived Docker image: +Agent groups can specify custom packages in their container config (stored in the `container_configs` DB table). The host builds a derived Docker image: - Tag: derived from the checkout-scoped base image and agent group - Built on top of `nanoclaw-agent-v2-:latest` - Adds custom apt and npm packages +- Resulting image tag is written back to the `container_configs.image_tag` column + +## Container configuration storage + +Per-agent-group runtime config lives in the `container_configs` table in the central DB. The legacy `groups//container.json` file is now a **materialized view** — written by the host at spawn time, read by the container at startup. The container has no idea the DB exists; nothing inside the container changed. + +### Schema + +The `container_configs` table has one row per agent group, with both scalar and JSON columns: + +| Column | Type | Purpose | +|---|---|---| +| `agent_group_id` | TEXT (PK, FK) | References `agent_groups.id` (cascades on delete) | +| `provider` | TEXT | Agent provider override (`claude`, `opencode`, etc.) | +| `model` | TEXT | Model name (e.g., `claude-sonnet-4-6`) | +| `effort` | TEXT | Reasoning effort hint | +| `image_tag` | TEXT | Persisted Docker image tag for per-group builds | +| `assistant_name` | TEXT | Display name in system prompt | +| `max_messages_per_prompt` | INTEGER | Override for `MAX_MESSAGES_PER_PROMPT` | +| `skills` | TEXT (JSON) | `"all"` or `["skill1", ...]` | +| `mcp_servers` | TEXT (JSON) | `Record` | +| `packages_apt` | TEXT (JSON) | `string[]` of apt packages | +| `packages_npm` | TEXT (JSON) | `string[]` of npm packages | +| `additional_mounts` | TEXT (JSON) | `AdditionalMountConfig[]` | +| `cli_scope` | TEXT | `disabled` \| `group` (default) \| `global` — controls in-container `ncl` access | +| `updated_at` | TEXT | ISO timestamp | + +### Backfill + +On startup, the host runs a one-time backfill that seeds `container_configs` rows from any existing `groups//container.json` files (and the legacy `agent_groups.agent_provider` column). The backfill is idempotent — it skips groups that already have a row. + +### Provider cascade + +Provider resolution simplified from a 3-step cascade to 2 steps: + +``` +sessions.agent_provider + → container_configs.provider + → 'claude' +``` + +The legacy `agent_groups.agent_provider` column is retained for backwards compat but no longer participates in resolution and is no longer exposed via `ncl groups`. Configure provider via `ncl groups config update --id --provider `. + +### Updating config + +All writes go through the DB layer. Multi-word verbs accept either spaces (preferred) or dashes — `ncl groups config get` and `ncl groups config-get` both work. + +- **`ncl groups config update`** — change scalar fields (`--provider`, `--model`, `--effort`, `--image-tag`, `--assistant-name`, `--max-messages-per-prompt`, `--cli-scope`) +- **`ncl groups config add-mcp-server`** / **`config remove-mcp-server`** — manage MCP servers +- **`ncl groups config add-package`** / **`config remove-package`** — manage apt/npm packages +- **Self-mod approvals** (`install_packages`, `add_mcp_server`) — write to DB instead of file +- **`ncl groups config get`** — view current config (open access; others require approval) + +Config CLI ops only write to the DB; restart is now a separate command — see "Explicit restart" below. The host helper `restartAgentGroupContainers()` is invoked by self-mod approvals to apply config changes. + +### CLI scope + +`cli_scope` controls what an in-container agent can do via `ncl`: + +| Value | Behavior | +|---|---| +| `disabled` | Agent never learns about `ncl` (the instructions section is excluded from the composed `CLAUDE.md`). The host CLI dispatcher rejects any `cli_request`. | +| `group` (default) | Agent can call `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id`, `--agent_group_id`, and `--group` args are auto-filled. Cross-group reads are rejected post-handler; help output reflects the scope. `cli_scope` arg is blocked outright. | +| `global` | Unrestricted — current behavior. Set automatically for owner agent groups by `init-first-agent`. | + +Enforcement is host-side only — no image rebuild or env var needed. + +### Explicit restart + +```bash +ncl groups restart --id [--rebuild] [--message ] +``` + +- Kills running containers for the agent group; without `--message`, they come back on the next inbound message +- With `--message`, an `on_wake` row is written to `inbound.db` and the container respawns immediately via the `onExit` callback +- `--rebuild` forces an image rebuild before respawn (useful after package changes); package commands no longer trigger a build implicitly +- Called from inside a container, `--id` is auto-filled and only the calling session is restarted + +The `on_wake` flag on `messages_in` ensures wake messages are delivered only on the new container's first poll iteration. Without it, the dying container — still in its SIGTERM grace window — could steal the message before exiting. `killContainer` accepts an `onExit` callback that fires after the process actually exits, guaranteeing race-free respawn. ## Two-database IO model @@ -120,8 +199,8 @@ Three invariants are critical for correctness: Containers are spawned by the `spawnContainer` function. Wake calls are deduplicated via an in-flight promise map. - - The host reads `container.json` and resolves provider contributions. + + The host reads the agent group's row from the `container_configs` DB table and writes it as `groups//container.json`. This file is a materialized view — the DB is the source of truth, and the file is regenerated on every spawn so the runner always sees fresh config. Provider contributions are resolved from this config. @@ -151,7 +230,7 @@ Containers are spawned by the `spawnContainer` function. Wake calls are deduplic |------|---------------|------|---------| | Session folder | `/workspace` | RW | inbound.db, outbound.db, outbox/, inbox/ | | Agent group folder | `/workspace/agent` | RW | Working files | -| container.json | `/workspace/agent/container.json` | RO | Nested read-only config | +| container.json | `/workspace/agent/container.json` | RO | Materialized from DB at spawn time | | Composed CLAUDE.md | `/workspace/agent/CLAUDE.md` | RO | Regenerated each spawn | | Global memory | `/workspace/global` | RO | Shared instructions | | Agent-runner source | `/app/src` | RO | Bind mount from host | diff --git a/advanced/troubleshooting.mdx b/advanced/troubleshooting.mdx index 8515e14..9fcbbb0 100644 --- a/advanced/troubleshooting.mdx +++ b/advanced/troubleshooting.mdx @@ -133,21 +133,24 @@ cat groups/{group}/logs/container-{timestamp}.log ### Solutions - - Modify the group's `containerConfig` in the database: - + + Container config lives in the `container_configs` table in `data/v2.db`. Inspect with the CLI: + ```bash - sqlite3 store/messages.db + ncl groups config get --id ``` - - ```sql - UPDATE registered_groups - SET container_config = json_set( - COALESCE(container_config, '{}'), - '$.timeout', - 3600000 -- 1 hour in milliseconds - ) - WHERE name = 'Family Chat'; + + Update scalar fields (e.g., model, effort, image tag, max messages per prompt). Config writes only touch the DB — to apply the change to a running container, follow up with `ncl groups restart`: + + ```bash + ncl groups config update --id --model claude-sonnet-4-6 --max-messages-per-prompt 20 + ncl groups restart --id + ``` + + Or query directly with SQLite: + + ```bash + sqlite3 data/v2.db "SELECT * FROM container_configs WHERE agent_group_id = '';" ``` @@ -370,8 +373,8 @@ grep -E 'Mount validated|Mount.*REJECTED|mount' logs/nanoclaw.log | tail -10 # Verify the mount allowlist is readable cat ~/.config/nanoclaw/mount-allowlist.json -# Check group's container_config in DB -sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups;" +# Check a group's container config (mounts and packages live here) +sqlite3 data/v2.db "SELECT agent_group_id, additional_mounts, packages_apt, packages_npm FROM container_configs;" ``` ### Solutions diff --git a/api/configuration.mdx b/api/configuration.mdx index 782d8de..ab3eb78 100644 --- a/api/configuration.mdx +++ b/api/configuration.mdx @@ -4,7 +4,7 @@ description: Environment variables and configuration options for NanoClaw contai tag: "UPDATED" --- -NanoClaw configuration is managed through environment variables, the `.env` file, and the `src/config.ts` module. In v2, some configuration has moved to `container.json` per agent group. +NanoClaw configuration is managed through environment variables, the `.env` file, and the `src/config.ts` module. In v2, per-agent-group configuration (provider, model, packages, MCP servers, mounts) lives in the `container_configs` table in the central DB and is materialized to `groups//container.json` at spawn time. See [Container runtime](/advanced/container-runtime#container-configuration-storage) for details. ## Environment variables diff --git a/api/group-management.mdx b/api/group-management.mdx index 0bfaa96..02fba38 100644 --- a/api/group-management.mdx +++ b/api/group-management.mdx @@ -17,14 +17,16 @@ interface AgentGroup { id: string; // Unique identifier name: string; // Display name folder: string; // Filesystem folder name - agent_provider?: string; // Optional provider override + /** @deprecated Use container_configs.provider instead. */ + agent_provider: string | null; created_at: string; // ISO timestamp } ``` - Each agent group has a folder under `groups/{folder}/` -- Container configuration lives on disk (`container.json`), not in the database +- Container configuration lives in the `container_configs` DB table (one row per agent group); the file `groups//container.json` is materialized at spawn time - Each gets its own OneCLI agent identifier for credential scoping +- `agent_provider` is deprecated and no longer exposed via the CLI — use `ncl groups config update --provider` to set the provider on the `container_configs` row instead ### Messaging groups @@ -45,6 +47,37 @@ interface MessagingGroup { - Auto-created on first mention or DM - `denied_at` silently drops future mentions +### Container configs + +Per-agent-group runtime config: + +```typescript +interface ContainerConfigRow { + agent_group_id: string; // PK and FK to agent_groups.id + provider: string | null; // 'claude', 'opencode', etc. + model: string | null; // Model name (e.g., 'claude-sonnet-4-6') + effort: string | null; // Reasoning effort hint + image_tag: string | null; // Persisted Docker image tag + assistant_name: string | null; // Display name in system prompt + max_messages_per_prompt: number | null; + skills: string; // JSON: '"all"' | '["skill1","skill2"]' + mcp_servers: string; // JSON: Record + packages_apt: string; // JSON: string[] + packages_npm: string; // JSON: string[] + additional_mounts: string; // JSON: AdditionalMountConfig[] + cli_scope: string; // 'disabled' | 'group' | 'global' (default 'group') + updated_at: string; +} +``` + +Source of truth in the DB. Materialized to `groups//container.json` at spawn time so the in-container runner can read it from the read-only mount. All writes go through `ncl groups config ` operations or self-mod approvals — see [Container runtime](/advanced/container-runtime#container-configuration-storage) for the full list. + +`cli_scope` controls what the in-container agent can do via `ncl`: + +- **`disabled`** — agent never learns about `ncl` (instructions excluded from `CLAUDE.md`); host dispatch rejects any `cli_request` +- **`group`** (default) — agent can call `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group; `--id` and group args are auto-filled and cross-group reads are rejected; `cli_scope` changes are blocked +- **`global`** — unrestricted; set automatically on the owner's first agent group via `init-first-agent` + ### Wirings (messaging_group_agents) Wirings connect messaging groups to agent groups: @@ -128,6 +161,7 @@ Session resolution depends on the wiring's `session_mode`: | Table | Purpose | |-------|---------| | `agent_groups` | Agent workspaces | +| `container_configs` | Per-agent-group runtime config (provider, model, packages, MCP servers, mounts) | | `messaging_groups` | Platform chats/channels | | `messaging_group_agents` | Wirings with engage/scope/session config | | `users` | Namespaced platform identifiers | diff --git a/concepts/architecture.mdx b/concepts/architecture.mdx index bc7f3f8..7b89b60 100644 --- a/concepts/architecture.mdx +++ b/concepts/architecture.mdx @@ -108,7 +108,8 @@ The host sweep (`src/host-sweep.ts`) runs every 60 seconds: **Central database** (`data/v2.db`) stores the entity model: -- **agent_groups** — workspaces with folder, name, and optional provider +- **agent_groups** — workspaces with folder and name (provider moved to `container_configs.provider`) +- **container_configs** — per-agent-group runtime config (provider, model, effort, packages, MCP servers, additional mounts); materialized to `groups//container.json` at spawn time - **messaging_groups** — platform chats with `unknown_sender_policy` (`strict`, `request_approval`, or `public`) - **messaging_group_agents** — many-to-many wirings with engage mode, pattern, sender scope, ignored message policy, session mode, and priority - **users** — namespaced platform identifiers (e.g., `phone:+1555...`, `tg:123`, `discord:456`) diff --git a/concepts/containers.mdx b/concepts/containers.mdx index c328ee0..df54011 100644 --- a/concepts/containers.mdx +++ b/concepts/containers.mdx @@ -74,7 +74,7 @@ Containers only see what's explicitly mounted. The v2 mount structure is differe |-------|---------------|------|---------| | Session folder | `/workspace` | Read-write | `inbound.db`, `outbound.db`, `outbox/`, `.claude/` | | Agent group folder | `/workspace/agent` | Read-write | Working files, `CLAUDE.local.md` | -| Container config | `/workspace/agent/container.json` | Read-only | Nested RO mount (agent can't modify config) | +| Container config | `/workspace/agent/container.json` | Read-only | Materialized from DB at spawn time (agent can't modify) | | Composed CLAUDE.md | `/workspace/agent/CLAUDE.md` | Read-only | Regenerated each spawn | | CLAUDE.md fragments | `/workspace/agent/.claude-fragments` | Read-only | Fragment files for composition | | Global memory | `/workspace/global` | Read-only | `groups/global/` directory | @@ -82,10 +82,10 @@ Containers only see what's explicitly mounted. The v2 mount structure is differe | Agent-runner source | `/app/src` | Read-only | Shared source (bind mount from host) | | Container skills | `/app/skills` | Read-only | Shared skill definitions | | Claude SDK state | `/home/node/.claude` | Read-write | SDK state + skill symlinks | -| Additional mounts | `/workspace/extra/{name}` | Per-config | From `container.json` (validated against allowlist) | +| Additional mounts | `/workspace/extra/{name}` | Per-config | From container config (validated against allowlist) | -The `container.json` file is mounted read-only as a nested mount inside the read-write agent group folder. This prevents the agent from modifying its own container configuration. +The `container.json` file is a **materialized view** of the DB-backed `container_configs` row. The host writes it at spawn time and mounts it read-only as a nested mount inside the read-write agent group folder. The agent cannot modify its own container configuration — config changes go through self-mod approvals or `ncl groups config ` commands. ### Mount security @@ -142,18 +142,31 @@ Container spawning is deduplicated — concurrent wake calls for the same sessio - **Host-initiated**: `docker stop` sends SIGTERM; `tini` forwards to Bun process - **Stale detection**: host sweep detects containers with old heartbeats or stuck processing_ack - **Fallback**: SIGKILL if graceful stop fails +- **`onExit` callback**: `killContainer` accepts an optional callback that fires after the process actually exits, used by the restart flow to guarantee the old container is gone before the new one spawns Even if the container crashes, all data in session databases and mounted directories persists. Only the container process itself is ephemeral. +### Explicit restart + +`ncl groups restart --id [--rebuild] [--message ]` kills running containers for the agent group and lets them respawn: + +- Without `--message`, containers come back on the next inbound message +- With `--message`, an `on_wake` message is queued in `inbound.db` and the container respawns immediately via the `onExit` callback +- `--rebuild` forces an image rebuild before respawn (useful after package changes) +- Called from inside a container, `--id` is auto-filled and only the calling session is restarted + +The `on_wake` flag on `messages_in` ensures wake messages are delivered only on the fresh container's first poll iteration. This prevents the dying container — still in its SIGTERM grace window — from stealing the message before it exits. + ## Per-agent-group images -Agent groups can specify custom packages in `container.json`. The host builds a derived Docker image with additional apt and npm packages: +Agent groups can specify custom packages in their container config (the `container_configs` DB table — materialized to `container.json` at spawn time). The host builds a derived Docker image with additional apt and npm packages: - Image tag: derived from the checkout-scoped base image and agent group - Built on top of the base `nanoclaw-agent-v2-:latest` image - Cached — only rebuilt when package lists change +- Image tag is persisted to the DB after build ## Timeouts @@ -187,7 +200,7 @@ The `nanoclaw` MCP server provides tools for container-to-host communication via - `schedule_task`, `cancel_task`, `pause_task`, `resume_task`, `update_task` — task management - `list_tasks` — view scheduled tasks -Additional MCP servers can be configured in `container.json`. +Additional MCP servers can be configured per-agent-group via `ncl groups config add-mcp-server` (writes to the `container_configs` DB table). ### Global memory injection diff --git a/concepts/groups.mdx b/concepts/groups.mdx index 381153b..357b155 100644 --- a/concepts/groups.mdx +++ b/concepts/groups.mdx @@ -13,8 +13,7 @@ In v2, NanoClaw uses a new entity model that separates **agent groups** (workspa An agent group is: - A workspace with its own folder under `groups/{name}/` -- An optional provider configuration -- A container configuration (`container.json`) with custom packages and mounts +- A row in the `container_configs` DB table with provider, model, effort, packages, MCP servers, and additional mounts - The unit of credential scoping (each gets its own OneCLI agent) ### Messaging groups @@ -137,7 +136,7 @@ The global memory directory (`groups/global/`) provides shared context: ## Additional mounts -Agent groups can have extra directories mounted via `container.json`: +Agent groups can have extra directories mounted via the `additional_mounts` JSON column on their `container_configs` row. The materialized `container.json` mounted into the container looks like this: ```json { diff --git a/features/customization.mdx b/features/customization.mdx index 9940fa8..80dfefc 100644 --- a/features/customization.mdx +++ b/features/customization.mdx @@ -173,7 +173,7 @@ Agents only see what you mount. The allowlist lives at `~/.config/nanoclaw/mount } ``` -Per-agent-group mount requests live in `groups//container.json`. The host validates each request against the allowlist before mounting. See [Security model](/advanced/security-model) for the full picture. +Per-agent-group mount requests live in the `additional_mounts` JSON column on the `container_configs` row, materialized to `groups//container.json` at spawn time. The host validates each request against the allowlist before mounting. See [Security model](/advanced/security-model) for the full picture. ## The `/customize` skill diff --git a/integrations/overview.mdx b/integrations/overview.mdx index 04a7551..cb6c97b 100644 --- a/integrations/overview.mdx +++ b/integrations/overview.mdx @@ -75,7 +75,7 @@ By default, NanoClaw uses Claude via the official [Claude Agent SDK](https://doc | `/add-ollama-provider` | Ollama | Local open-weight models — no cloud calls | | `/add-ollama-tool` | Ollama (tool mode) | Use local Ollama as a tool inside Claude agents | -`agent_provider` is a column on `agent_groups` in the central database — each agent group can run a different provider. Mix and match in the same install. +Provider is configured per-agent-group via the `container_configs.provider` column in the central database — each agent group can run a different provider. Set it with `ncl groups config update --id --provider `. Mix and match in the same install. ## How channel skills work