Why containers?
Containers provide:- Process isolation - Agent processes can’t affect the host system
- Filesystem isolation - Agents only see explicitly mounted directories
- Resource limits - CPU/memory can be constrained (future)
- Ephemeral execution - Fresh environment per invocation, no persistence
- Non-root execution - Agents run as unprivileged user
NanoClaw uses Docker by default for cross-platform compatibility. On macOS, you can use Apple Container instead (via
/convert-to-apple-container skill).Container architecture
Base image
The container is built fromcontainer/Dockerfile:
- Base:
node:24-slim(Debian-based, minimal) - Browser: Chromium with all dependencies
- Tools:
agent-browserfor browser automation - Runtime:
@anthropic-ai/claude-code(Claude Agent SDK) - User:
node(uid 1000, non-root) - Working directory:
/workspace/group
Entrypoint flow
The entrypoint script (/app/entrypoint.sh):
- Recompile agent-runner:
npx tsc --outDir /tmp/dist- Allows per-group customization of agent runner code
- Compiled output is read-only to prevent runtime tampering
- Read input from stdin:
cat > /tmp/input.json- Input contains prompt, session ID, and group metadata (not secrets)
- Execute agent:
node /tmp/dist/index.js < /tmp/input.json- Runs agent-runner with input
- Credentials handled by the secret injection layer (OneCLI gateway or credential proxy), never passed via stdin
- Outputs JSON results to stdout
- Container exits: Cleaned up automatically via
--rmflag
The agent-runner is recompiled on every container start from
/app/src (mounted from data/sessions/{group}/agent-runner-src/). This allows agents to modify their own tools and behavior by editing their agent-runner source. The agent-runner source is copied once when the group is first created — subsequent changes to container/agent-runner/src/ are not automatically propagated to existing groups.Volume mounts
Containers only see what’s explicitly mounted:Main group mounts
.env file exists on the host, it is shadowed with /dev/null to prevent agents from reading secrets:
Non-main group mounts
Mount security
All mounts are validated against the allowlist at~/.config/nanoclaw/mount-allowlist.json:
- Resolve symlinks to real path
- Check against blocked patterns (
.ssh,.env, etc.) - Verify path is under an allowed root
- Enforce
nonMainReadOnlyfor non-main groups - Enforce per-root
allowReadWritesetting - Reject container paths with
..or absolute paths
Container lifecycle
Spawn
- If host uid is 0 (root): No
--userflag set; Dockerfile default (node, uid 1000) applies - If host uid is 1000: No
--userflag set (already matches container default) - If
process.getuidis unavailable (native Windows without WSL): No--userflag set - Otherwise: Run as host uid/gid (via
--userflag), withHOME=/home/nodeexplicitly set
Execute
- Input passed: Prompt and group metadata via stdin JSON
- Stdout streaming: Parsed for output markers in real-time
- Stderr logging: Logged at debug level (SDK writes lots of debug info)
- Timeout tracking: Hard timeout with activity-based reset
- Graceful shutdown:
docker stop -t 1(SIGTERM with 1-second grace), wrapped in a 15-second exec timeout. If the stop command itself hangs, the process is force-killed viaSIGKILL
Cleanup
Containers are ephemeral:--rmflag: Docker removes container on exit- Logs persisted: Written to
groups/{folder}/logs/before removal - Sessions persisted:
.claude/mounted, survives container death - Group files persisted:
/workspace/groupmounted, survives restart
Even if the container crashes, all data in mounted directories persists. Only the container itself is ephemeral.
Output streaming
The container uses sentinel markers for robust output parsing:- SDK writes debug logs to stdout
- Markers ensure JSON isn’t corrupted by debug output
- Supports multiple outputs per container (streaming)
- Robust against stderr bleeding into stdout
onOutput callback is provided:
- Parse buffer accumulates stdout chunks
- Each complete marker pair triggers
onOutput(parsed) - Session ID tracked across multiple outputs
- Container stays alive between outputs (idle timeout)
_closesentinel in IPC signals graceful shutdown
Timeouts
Two timeout mechanisms:Hard timeout (container timeout)
- Default: 30 minutes (
CONTAINER_TIMEOUT) - Configurable: Per-group via
containerConfig.timeout - Grace period: At least
IDLE_TIMEOUT + 30s - Reset on activity: Resets whenever output markers are parsed
- Enforcement:
docker stop -t 1(SIGTERM with 1-second grace), wrapped in a 15-second exec timeout. Falls back toSIGKILLif the stop command hangs
Idle timeout (stdin close)
- Default: 30 minutes (
IDLE_TIMEOUT) - Purpose: Close container when no follow-up messages arrive
- Mechanism: Write
_closesentinel to IPC input directory - Agent behavior: Exit gracefully when
_closedetected - Reset on: New messages piped to container
- Tasks use shorter close delay: 10 seconds
- Tasks are single-turn (no follow-ups expected)
- Closes automatically after producing result
Idle timeout is agent-cooperative (relies on agent checking IPC). Hard timeout is enforced by the container runtime (kills process if exceeded).
Resource limits
Currently no CPU/memory limits enforced. Future enhancement:Logging
All container runs are logged: Log location:groups/{folder}/logs/container-{timestamp}.log
Log contents (verbose mode or errors):
- Success + non-verbose: Summary only (input length, mount paths)
- Success + verbose (
LOG_LEVEL=debug): Full input/output including prompt content - Error: Input metadata only (prompt length and session ID), plus full stderr/stdout
User prompt content is never written to error logs. Only verbose mode (
LOG_LEVEL=debug) includes the full prompt. This prevents sensitive conversation content from persisting on disk during normal error handling.Set
LOG_LEVEL=debug in .env to enable verbose logging for all container runs.Container runtime
NanoClaw detects and uses the available container runtime:/convert-to-apple-container skill.
Supported runtimes:
- Docker (default): Cross-platform, well-tested
- Apple Container (macOS): Lightweight, native virtualization
Skills and MCP servers
Skills synced to each container:- Copy from project: Skills from
container/skills/are synced todata/sessions/{group}/.claude/skills/on every container spawn (overwriting existing files) - Available to agent: Claude Agent SDK loads from
.claude/skills/ - Per-group customization: Each group gets a copy, but changes are overwritten on next container spawn (unlike agent-runner source, which is copied once)
Built-in container skills
| Skill | Description | Access |
|---|---|---|
/agent-browser | Browse the web, fill forms, extract data | All groups |
/capabilities | Report installed skills, available tools, MCP tools, container utilities, and group info | Main channel only |
/slack-formatting | Format messages for Slack using mrkdwn syntax | All groups |
/status | Quick health check — session context, workspace mounts, tool availability, and scheduled task snapshot | Main channel only |
/capabilities and /status are restricted to the main channel. They detect this by checking for the /workspace/project mount, which is only present for main groups. Non-main groups receive a redirect message.Allowed tools
The agent runner passes an explicit allowlist to the Claude Agent SDK. Only these tools are available inside containers:| Category | Tools |
|---|---|
| File operations | Bash, Read, Write, Edit, Glob, Grep |
| Web access | WebSearch, WebFetch |
| Agent teams | Task, TaskOutput, TaskStop, TeamCreate, TeamDelete, SendMessage |
| Utilities | TodoWrite, ToolSearch, Skill, NotebookEdit |
| MCP | mcp__nanoclaw__* (all tools from the built-in NanoClaw MCP server) |
permissionMode: 'bypassPermissions' since containers already provide the security boundary.
Conversation archival
Before the SDK compacts context (when the conversation gets long), aPreCompact hook archives the full transcript to /workspace/group/conversations/ as a markdown file. Files are named {date}-{summary}.md where the summary is derived from the session context. This preserves conversation history that would otherwise be lost during compaction.
Global memory injection
For non-main groups, if/workspace/global/CLAUDE.md exists, its contents are appended to the system prompt via systemPrompt.append. This lets you define shared instructions in groups/global/CLAUDE.md that apply to all non-main groups without duplicating the file.
Additional directory auto-discovery
Directories mounted at/workspace/extra/* (via additional mounts) are automatically passed to the SDK as additionalDirectories. This means any CLAUDE.md file in those directories is loaded automatically, giving the agent context about the mounted project.
MCP servers:
- Configured in agent-runner source (
container/agent-runner/src/index.ts) - Built-in:
nanoclawMCP server (8 tools:send_message,schedule_task,list_tasks,pause_task,resume_task,cancel_task,update_task,register_group) - Custom: Add by modifying agent-runner code
Adding a custom MCP server
Adding a custom MCP server
- Edit
data/sessions/{group}/agent-runner-src/index.ts - Add the MCP server to the
mcpServersconfig dictionary passed to the SDKquery()call: - Next container spawn will recompile with new server
- Tools available to agent immediately
Browser automation
Chromium runs inside the container:- Executable:
/usr/bin/chromium - CLI:
agent-browser(installed globally) - Headless: Always (no display in container)
- User data: Stored in group folder (persists across runs)
- Network: Full access (same as host, no restrictions)
Security implications
What containers protect against
- Filesystem access: Agents can’t read
~/.sshor other sensitive paths - Process interference: Agents can’t kill host processes or inject code
- Persistence: Containers are ephemeral, no state survives unless mounted
- Privilege escalation: Non-root execution limits kernel attack surface
What containers DON’T protect against
- Network access: Agents have full network access (can exfiltrate data)
- Mounted directory tampering: Agents can modify anything in mounted directories
- Gateway-based API access: Containers can make authenticated API requests through the secret injection layer (though they cannot extract real credentials)
- Resource exhaustion: No CPU/memory limits (can DoS host)
Troubleshooting
Container won’t start
- Check Docker is running:
docker ps - Check image exists:
docker images | grep nanoclaw-agent - Rebuild image:
./container/build.sh - Check logs:
groups/{folder}/logs/
Container timeout
- Check timeout setting:
CONTAINER_TIMEOUTin.env - Check if task is legitimately slow (increase timeout)
- Check idle timeout:
IDLE_TIMEOUT(controls stdin close) - Review logs for last activity timestamp
Permission errors
- Check mount paths are readable by host user
- Check uid/gid mapping (logged in verbose mode)
- Verify allowlist includes path (for additional mounts)
- Check symlink resolution didn’t change path
Output not parsed
- Check for output markers in logs
- Verify agent-runner is writing markers correctly
- Check stdout isn’t truncated (
CONTAINER_MAX_OUTPUT_SIZE) - Review stderr for SDK errors