1	# NanoClaw — Central DB Schema
2
3	Complete reference for `data/v2.db`, the host-owned admin-plane database. Start with [db.md](db.md) for the three-DB overview, the map, and the cross-mount rules.
4
5	Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (comments only — actual creation runs via migrations in `src/db/migrations/`).
6
7	---
8
9	## 1. Tables
10
11	### 1.1 `agent_groups`
12
13	Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md` and skills. Container config lives in `container_configs` (see §1.x below); a `container.json` file is materialized at spawn time for the container runner to read.
14
15	```sql
16	CREATE TABLE agent_groups (
17	  id               TEXT PRIMARY KEY,
18	  name             TEXT NOT NULL,
19	  folder           TEXT NOT NULL UNIQUE,
20	  agent_provider   TEXT,
21	  created_at       TEXT NOT NULL
22	);
23	```
24
25	- **Readers:** `src/session-manager.ts`, `src/delivery.ts`, `src/router.ts`
26	- **Writers:** `src/db/agent-groups.ts`
27
28	### 1.2 `messaging_groups`
29
30	One row per platform chat (one WhatsApp group, one Slack channel, one 1:1 DM, etc.).
31
32	```sql
33	CREATE TABLE messaging_groups (
34	  id                    TEXT PRIMARY KEY,
35	  channel_type          TEXT NOT NULL,
36	  platform_id           TEXT NOT NULL,
37	  name                  TEXT,
38	  is_group              INTEGER DEFAULT 0,
39	  unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
40	  created_at            TEXT NOT NULL,
41	  UNIQUE(channel_type, platform_id)
42	);
43	```
44
45	- `unknown_sender_policy`: `strict` (drop), `request_approval` (ask admin), `public` (allow).
46	- **Readers:** `src/router.ts`, `src/delivery.ts`, `src/session-manager.ts`
47	- **Writers:** `src/db/messaging-groups.ts`, channel setup flows
48
49	### 1.3 `messaging_group_agents`
50
51	Wiring: which agent group handles which messaging group. Many-to-many — the same channel can route to multiple agents (see [isolation-model.md](isolation-model.md)).
52
53	```sql
54	CREATE TABLE messaging_group_agents (
55	  id                 TEXT PRIMARY KEY,
56	  messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
57	  agent_group_id     TEXT NOT NULL REFERENCES agent_groups(id),
58	  trigger_rules      TEXT,
59	  response_scope     TEXT DEFAULT 'all',
60	  session_mode       TEXT DEFAULT 'shared',
61	  priority           INTEGER DEFAULT 0,
62	  created_at         TEXT NOT NULL,
63	  UNIQUE(messaging_group_id, agent_group_id)
64	);
65	```
66
67	- `session_mode`: `shared` (one session per channel), `per-thread` (one per thread), `agent-shared` (one per agent group across all channels).
68	- `trigger_rules`: JSON; e.g. regex for native channels.
69	- **Side effect:** creating a wiring must also populate `agent_destinations` — don't mutate one without the other (see §1.10).
70
71	### 1.4 `users`
72
73	Platform user identities. ID is namespaced: `tg:123456`, `discord:abc`, `phone:+1555...`, `email:a@x.com`. One human may own several rows — no cross-channel linking yet.
74
75	```sql
76	CREATE TABLE users (
77	  id           TEXT PRIMARY KEY,
78	  kind         TEXT NOT NULL,
79	  display_name TEXT,
80	  created_at   TEXT NOT NULL
81	);
82	```
83
84	- **Writers/readers:** `src/db/users.ts`; channel auth flows
85
86	### 1.5 `user_roles`
87
88	Permissions. **Privilege is user-level, never agent-group-level.**
89
90	```sql
91	CREATE TABLE user_roles (
92	  user_id        TEXT NOT NULL REFERENCES users(id),
93	  role           TEXT NOT NULL,
94	  agent_group_id TEXT REFERENCES agent_groups(id),
95	  granted_by     TEXT REFERENCES users(id),
96	  granted_at     TEXT NOT NULL,
97	  PRIMARY KEY (user_id, role, agent_group_id)
98	);
99	CREATE INDEX idx_user_roles_scope ON user_roles(agent_group_id, role);
100	```
101
102	Invariants:
103	- `role = 'owner'` → must be global (`agent_group_id IS NULL`). Enforced in `grantRole()`.
104	- `role = 'admin'` → global (NULL) or scoped to one agent group.
105	- Admin @ A implies membership in A — no `agent_group_members` row required.
106
107	Access layer: `src/db/user-roles.ts`, `src/access.ts`.
108
109	### 1.6 `agent_group_members`
110
111	Explicit membership for non-privileged users. Owner and admins don't need rows here — they're implicit members.
112
113	```sql
114	CREATE TABLE agent_group_members (
115	  user_id        TEXT NOT NULL REFERENCES users(id),
116	  agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
117	  added_by       TEXT REFERENCES users(id),
118	  added_at       TEXT NOT NULL,
119	  PRIMARY KEY (user_id, agent_group_id)
120	);
121	```
122
123	### 1.7 `user_dms`
124
125	Cache of DM channel discovery. Lets the host send a cold DM (approval card, pairing code) without hitting the platform's `openConversation` API every time.
126
127	```sql
128	CREATE TABLE user_dms (
129	  user_id            TEXT NOT NULL REFERENCES users(id),
130	  channel_type       TEXT NOT NULL,
131	  messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
132	  resolved_at        TEXT NOT NULL,
133	  PRIMARY KEY (user_id, channel_type)
134	);
135	```
136
137	Populated lazily by `ensureUserDm()` in `src/user-dm.ts`.
138
139	### 1.8 `sessions`
140
141	Session registry. One row per (agent group, messaging group, thread) tuple subject to `session_mode`. Stores lifecycle metadata only — no messages.
142
143	```sql
144	CREATE TABLE sessions (
145	  id                 TEXT PRIMARY KEY,
146	  agent_group_id     TEXT NOT NULL REFERENCES agent_groups(id),
147	  messaging_group_id TEXT REFERENCES messaging_groups(id),
148	  thread_id          TEXT,
149	  agent_provider     TEXT,
150	  status             TEXT DEFAULT 'active',


Bash

grep -A 30 "container_configs" /tmp/nanoclaw-main/docs/db-central.md | head -50


Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md` and skills. Container config lives in `container_configs` (see §1.x below); a `container.json` file is materialized at spawn time for the container runner to read.

```sql
CREATE TABLE agent_groups (
  id               TEXT PRIMARY KEY,
  name             TEXT NOT NULL,
  folder           TEXT NOT NULL UNIQUE,
  agent_provider   TEXT,
  created_at       TEXT NOT NULL
);
```

- **Readers:** `src/session-manager.ts`, `src/delivery.ts`, `src/router.ts`
- **Writers:** `src/db/agent-groups.ts`

### 1.2 `messaging_groups`

One row per platform chat (one WhatsApp group, one Slack channel, one 1:1 DM, etc.).

```sql
CREATE TABLE messaging_groups (
  id                    TEXT PRIMARY KEY,
  channel_type          TEXT NOT NULL,
  platform_id           TEXT NOT NULL,
  name                  TEXT,
  is_group              INTEGER DEFAULT 0,
  unknown_sender_policy TEXT NOT NULL DEFAULT 'strict',
  created_at            TEXT NOT NULL,
  UNIQUE(channel_type, platform_id)
);
```
--
### 1.15 `container_configs`

Per-agent-group container runtime config. Source of truth for provider, model, packages, MCP servers, mounts, CLI scope, etc. Materialized to `groups/<folder>/container.json` at spawn time.

```sql
CREATE TABLE container_configs (
  agent_group_id         TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
  provider               TEXT,
  model                  TEXT,
  effort                 TEXT,
  image_tag              TEXT,
  assistant_name         TEXT,
  max_messages_per_prompt INTEGER,
  skills                 TEXT NOT NULL DEFAULT '"all"',
  mcp_servers            TEXT NOT NULL DEFAULT '{}',
  packages_apt           TEXT NOT NULL DEFAULT '[]',
  packages_npm           TEXT NOT NULL DEFAULT '[]',
  additional_mounts      TEXT NOT NULL DEFAULT '[]',


Perfect! Now let me create a comprehensive summary. Let me also check the config file for the allowlist path location:


1	import os from 'os';
2	import path from 'path';
3
4	import { readEnvFile } from './env.js';
5	import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from './install-slug.js';
6	import { isValidTimezone } from './timezone.js';
7
8	// Read config values from .env (falls back to process.env).
9	const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL', 'ONECLI_API_KEY', 'TZ']);
10
11	export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
12	export const ASSISTANT_HAS_OWN_NUMBER =
13	  (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
14
15	// Absolute paths needed for container mounts
16	const PROJECT_ROOT = process.cwd();
17	const HOME_DIR = process.env.HOME || os.homedir();
18
19	// Mount security: allowlist stored OUTSIDE project root, never mounted into containers
20	export const MOUNT_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'mount-allowlist.json');
21	export const SENDER_ALLOWLIST_PATH = path.join(HOME_DIR, '.config', 'nanoclaw', 'sender-allowlist.json');
22	export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
23	export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
24	export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
25
26	// Per-checkout image tag so two installs on the same host don't share
27	// `nanoclaw-agent:latest` and clobber each other on rebuild.
28	export const CONTAINER_IMAGE_BASE = process.env.CONTAINER_IMAGE_BASE || getContainerImageBase(PROJECT_ROOT);
29	export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || getDefaultContainerImage(PROJECT_ROOT);
30	// Install slug — stamped onto every spawned container via --label so
31	// cleanupOrphans only reaps containers from this install, not peers.
32	export const INSTALL_SLUG = getInstallSlug(PROJECT_ROOT);
33	export const CONTAINER_INSTALL_LABEL = `nanoclaw-install=${INSTALL_SLUG}`;
34	export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10);
35	export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default
36	export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL;
37	export const ONECLI_API_KEY = process.env.ONECLI_API_KEY || envConfig.ONECLI_API_KEY;
38	export const MAX_MESSAGES_PER_PROMPT = Math.max(1, parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10);
39	export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
40	export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5);
41
42	function escapeRegex(str: string): string {
43	  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
44	}
45
46	export function buildTriggerPattern(trigger: string): RegExp {
47	  return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i');
48	}
49
50	export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`;
51
52	export function getTriggerPattern(trigger?: string): RegExp {
53	  const normalizedTrigger = trigger?.trim();
54	  return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER);
55	}
56
57	export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER);
58
59	// Timezone for scheduled tasks, message formatting, etc.
60	// Validates each candidate is a real IANA identifier before accepting.
61	function resolveConfigTimezone(): string {
62	  const candidates = [process.env.TZ, envConfig.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
63	  for (const tz of candidates) {
64	    if (tz && isValidTimezone(tz)) return tz;
65	  }
66	  return 'UTC';
67	}
68	export const TIMEZONE = resolveConfigTimezone();
69


Perfect! Now I have all the information. Let me compile the comprehensive report.

---

## Summary: `additional_mounts` Configuration in NanoClaw

Based on my analysis of the NanoClaw repository, here's exactly how `additional_mounts` is configured for agent containers:

### 1. Where `additional_mounts` is Read and Applied to Docker Config

**File: `/tmp/nanoclaw-main/src/container-runner.ts` (lines 242-334)**

The mounts are applied in the `buildMounts()` function, which:
- Reads `containerConfig.additionalMounts` from the materialized config
- Validates each mount using `validateAdditionalMounts()`
- Appends validated mounts to the Docker volume list
- Converts validated mounts to Docker `-v` arguments in `buildContainerArgs()` (lines 446-453)

**Mount Processing Flow:**
1. `materializeContainerJson()` reads from DB and writes to `groups/<folder>/container.json`
2. `buildMounts()` validates mounts against the allowlist
3. `buildContainerArgs()` converts to Docker CLI args (either `-v` or readonly mounts)

**Validated mounts are automatically mounted to: `/workspace/extra/<relative-path>`** (see line 334 in mount-security module)

---

### 2. Mount Configuration File (Host-Side, Outside Project)

**Location: `~/.config/nanoclaw/mount-allowlist.json`**

This is the **external allowlist** that controls which host paths can be mounted. Defined in:
- **Config location set in:** `/tmp/nanoclaw-main/src/config.ts` (line 20)
- **Module that validates:** `/tmp/nanoclaw-main/src/modules/mount-security/index.ts`

**The allowlist file must contain:**

```json
{
  "allowedRoots": [
    {
      "path": "~/projects",
      "allowReadWrite": true,
      "description": "Development projects"
    },
    {
      "path": "~/Documents/work",
      "allowReadWrite": false,
      "description": "Work documents (read-only)"
    }
  ],
  "blockedPatterns": [
    "password",
    "secret",
    "token"
  ]
}
```

**Default blocked patterns** (built-in, always enforced):
`.ssh`, `.gnupg`, `.gpg`, `.aws`, `.azure`, `.gcloud`, `.kube`, `.docker`, `credentials`, `.env`, `.netrc`, `.npmrc`, `.pypirc`, `id_rsa`, `id_ed25519`, `private_key`, `.secret`

**Key security feature:** The allowlist is **stored OUTSIDE the project root** and **never mounted into containers**, preventing agents from modifying mount permissions.

---

### 3. How Initial Mounts Are Added

There is **no admin UI, seed script, or migration** that pre-populates mounts. Instead:

**Option A: Programmatically via Database Insert**
- Use `updateContainerConfigJson()` in `src/db/container-configs.ts` (line 83-93)
- Example:
  ```typescript
  updateContainerConfigJson(agentGroupId, 'additional_mounts', [
    { hostPath: '/home/user/projects/my-repo', containerPath: 'my-repo', readonly: false }
  ])
  ```

**Option B: Directly in the `groups/<folder>/container.json` file** (legacy, pre-DB migration)
- The `backfillContainerConfigs()` function in `src/backfill-container-configs.ts` reads legacy `container.json` files and migrates them to the DB
- This only runs once per group (idempotent check on line 35)

**Option C: Through CLI if a command existed**
- Currently **there is NO CLI command** for managing mounts (unlike `config add-mcp-server`, `config add-package`, etc.)
- Mounts can only be viewed via `ncl groups config get <id>` (which shows the JSON but provides no edit interface)

---

### 4. Exact DB Table and Column Schema

**Table:** `container_configs`
**Column:** `additional_mounts` (TEXT, JSON)

**Full schema from migration 014:**

```sql
CREATE TABLE container_configs (
  agent_group_id        TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
  provider              TEXT,
  model                 TEXT,
  effort                TEXT,
  image_tag             TEXT,
  assistant_name        TEXT,
  max_messages_per_prompt INTEGER,
  skills                TEXT NOT NULL DEFAULT '"all"',
  mcp_servers           TEXT NOT NULL DEFAULT '{}',
  packages_apt          TEXT NOT NULL DEFAULT '[]',
  packages_npm          TEXT NOT NULL DEFAULT '[]',
  additional_mounts     TEXT NOT NULL DEFAULT '[]',
  cli_scope             TEXT,
  updated_at            TEXT NOT NULL
);
```

**JSON schema stored in `additional_mounts`:**
```typescript
interface AdditionalMountConfig {
  hostPath: string;
  containerPath?: string;  // Optional; defaults to basename of hostPath
  readonly?: boolean;       // Optional; defaults to true unless allowed root permits RW
}
```

---

### Key Files Summary

| File Path | Purpose |
|-----------|---------|
| `/src/container-config.ts` | Type definitions and materialization to `container.json` |
| `/src/container-runner.ts` | Applies mounts to Docker via `buildMounts()` and `buildContainerArgs()` |
| `/src/db/container-configs.ts` | DB access layer (`getContainerConfig`, `updateContainerConfigJson`) |
| `/src/db/migrations/014-container-configs.ts` | Table schema definition |
| `/src/modules/mount-security/index.ts` | Validation logic against `~/.config/nanoclaw/mount-allowlist.json` |
| `/src/backfill-container-configs.ts` | One-time migration from legacy disk files to DB |
| `/src/cli/resources/groups.ts` | CLI commands (get/update config, but no mount-specific commands) |
| `/docs/db-central.md` | Schema documentation |
| `/docs/SECURITY.md` | Mount security overview |

---

### Important Behavior Details

1. **Validation happens at spawn time** (not at config-write time) — invalid mounts are silently dropped with warnings, container still starts
2. **Read-write status is determined by both:**
   - The mount's `readonly` flag (if set to `false`)
   - AND the allowlist root's `allowReadWrite` setting
   - If either denies RW, the mount is forced read-only
3. **Container paths are auto-prefixed with `/workspace/extra/`** to isolate additional mounts from the core workspace
4. **No CLI add/remove commands for mounts exist** — must edit the DB directly or use the legacy file approach
5. **Changes take effect only after `ncl groups restart`** (same as other config updates)