Profiles - mensfeld/code-on-incus GitHub Wiki

Profiles are reusable, self-contained container configurations. Each profile bundles image, tool settings, limits, mounts, build scripts, environment variables, network config, and optional context files into a named directory.

Directory Structure

Each profile is a directory under profiles/ containing a config.toml and optional supporting files:

.coi/
├── config.toml              # project config
└── profiles/
    ├── rust-dev/
    │   ├── config.toml      # profile config
    │   ├── build.sh         # profile-specific build script
    │   └── CONTEXT.md       # AI agent context (appended to sandbox context)
    └── python-ml/
        ├── config.toml
        └── setup.sh

Profile directories are scanned at two config levels:

Priority Location
1 (lowest) ~/.coi/profiles/NAME/config.toml (user)
2 (highest) ./.coi/profiles/NAME/config.toml (project)

Profiles from all discovered locations are merged into a single namespace. If the same profile name is defined in more than one location, COI refuses to start and asks you to rename one so it's always unambiguous which profile is being applied. Operational CLI flags (--persistent, --slot, etc.) always override profile settings.

Profile Config Reference

A profile config.toml uses the same sections as the main config. All fields are optional — only set what you want to override.

# .coi/profiles/rust-dev/config.toml

context = "CONTEXT.md"                    # context file (see below)
forward_env = ["CARGO_REGISTRY_TOKEN"]

[container]
image = "coi-rust"
persistent = true

[container.build]
base = "coi-default"
script = "build.sh"           # resolved relative to this config.toml

[environment]
RUST_BACKTRACE = "1"

[tool]
name = "claude"
permission_mode = "bypass"

[tool.claude]
effort_level = "high"

[[mounts]]
host = "~/.cargo"
container = "/home/code/.cargo"

[network]
mode = "restricted"
# allowed_domains = ["crates.io", "github.com"]

[limits.cpu]
count = "4"

[limits.memory]
limit = "4GiB"

[limits.runtime]
max_duration = "4h"

Available Fields

Field Type Description
context string Path to context file (see Context Files)
forward_env string[] Host env vars to forward into the container
[container] section Container settings (image, persistent, storage_pool, alias)
[container.build] section Custom image build (base, script, commands)
[environment] map Static environment variables
[tool] section AI tool config (name, binary, permission_mode, context_file, auto_context)
[tool.claude] section Claude-specific settings (effort_level)
[[mounts]] array Additional mount points (host, container, readonly). Note: In profiles, use [[mounts]]; in the main config, use [[mounts.default]]
[network] section Network isolation (mode, allowed_domains)
[limits.cpu] section CPU limits (count, allowance, priority)
[limits.memory] section Memory limits (limit, enforce, swap)
[limits.disk] section Disk I/O limits (read, write, max)
[limits.runtime] section Runtime limits (max_duration, max_processes)
inherits string Parent profile name for inheritance (see Inheritance)
model string Override default AI model
[paths] section Path overrides (sessions_dir, storage_dir, logs_dir, preserve_workspace_path)
[incus] section Incus settings (project, group, code_uid, code_user)
[git] section Git settings (writable_hooks)
[ssh] section SSH settings (forward_agent)
[security] section Security settings (host_immutable, protected_paths)
[monitoring] section Security monitoring settings
[timezone] section Timezone settings (mode, name)

Profile Inheritance

Profiles can inherit from a parent using inherits = "parent-name", so you only override what differs:

# .coi/profiles/rust-dev-nightly/config.toml
inherits = "rust-dev"

[container]
image = "coi-rust-nightly"

[environment]
RUST_CHANNEL = "nightly"

Merge strategy:

  • Environment maps deep-merge (child keys win; set to "" to clear a parent key)
  • Arrays (mounts, forward_env) fully replace if the child defines them
  • Scalars override if set by the child
  • Struct sections (limits, tool, build, network) deep-merge field by field

Inheritance works across config levels (a project profile can inherit from a user-level profile), supports chains up to 10 levels, and has cycle detection. The built-in default profile can be used as a parent via inherits = "default".

Context Files

Profiles can include a context file — a markdown file with AI-agent-specific instructions that gets automatically appended to the sandbox context when the profile is used.

# .coi/profiles/python-ml/config.toml
context = "CONTEXT.md"
<!-- .coi/profiles/python-ml/CONTEXT.md -->
## Python ML Project Guidelines

- Use pytest for all testing
- Follow PEP 8 style conventions
- Use type hints for all function signatures
- Prefer numpy vectorized operations over loops

How It Works

When coi shell --profile python-ml is used:

  1. The context file path is resolved relative to the profile directory (absolute and ~ paths also work)
  2. The file is validated — if it doesn't exist, the session fails with a clear error
  3. The content is appended to ~/SANDBOX_CONTEXT.md under a # User-Provided Profile Context heading
  4. The content is also appended to tool-native auto-context files (e.g., ~/.claude/CLAUDE.md for Claude Code)

This means the AI agent automatically receives your profile-specific instructions on top of the standard sandbox environment info — no manual setup needed.

Naming

The context file can be named anything (not just CONTEXT.md). The context field just needs to point to a valid file:

context = "instructions.md"
context = "AI_GUIDELINES.md"
context = "../shared/common-context.md"    # relative paths work
context = "~/global-context.md"            # tilde expansion works

Build Scripts

Profiles can specify a build script that runs on top of a base image to create a custom container image:

[container.build]
base = "coi-default"              # base image to build on
script = "build.sh"       # script path (relative to profile dir)

The script is resolved relative to the profile directory. Example build script:

#!/bin/bash
# .coi/profiles/rust-dev/build.sh
apt-get update && apt-get install -y rustup
rustup default stable

You can also use inline commands instead of a script:

[container.build]
base = "coi-default"
commands = ["apt-get update", "apt-get install -y rustup"]

Commands

List Profiles

coi profile list

Shows all loaded profiles in a table:

NAME        IMAGE         PERSISTENT  SOURCE
python-ml   coi-default   -           .coi/profiles/python-ml/config.toml
rust-dev    coi-rust      true        .coi/profiles/rust-dev/config.toml
limited     coi-default   false       ~/.coi/profiles/limited/config.toml

Show Profile Details

coi profile info rust-dev

Displays the full configuration of a named profile:

Profile: rust-dev
Source:  .coi/profiles/rust-dev/config.toml

context = "/path/to/.coi/profiles/rust-dev/CONTEXT.md"
forward_env = ["CARGO_REGISTRY_TOKEN"]

[container]
image = "coi-rust"
persistent = true

[container.build]
base = "coi-default"
script = "/path/to/.coi/profiles/rust-dev/build.sh"

[environment]
RUST_BACKTRACE = "1"

[tool]
name = "claude"
permission_mode = "bypass"

[tool.claude]
effort_level = "high"

[limits.cpu]
count = "4"

Use a Profile

coi shell --profile rust-dev

Profile settings are merged into the running config. CLI flags override profile values:

# Uses rust-dev profile but overrides persistence
coi shell --profile rust-dev --persistent

Create a Profile

coi profile create rust-dev --image coi-rust --inherits default
coi profile create limited --persistent --project

Flags:

  • --image <alias> — set the profile's image
  • --inherits <parent> — set a parent profile to inherit from
  • --persistent — set persistent = true
  • --user — force creation in ~/.coi/profiles/
  • --project — force creation in ./.coi/profiles/

Without --user/--project, the location auto-detects: project .coi/ if it exists, otherwise ~/.coi/.

Edit a Profile

coi profile edit rust-dev
EDITOR=nano coi profile edit rust-dev

Opens the profile's config.toml in $VISUAL, $EDITOR, or vi. After the editor exits, the file is re-parsed and validated — warnings are printed on invalid TOML but the file is not deleted.

Delete a Profile

coi profile delete rust-dev           # Interactive confirmation
coi profile delete old-profile --force  # Skip confirmation

The built-in default profile cannot be edited or deleted.

Examples

Minimal Profile

# .coi/profiles/quick/config.toml
[container]
persistent = false

[limits.runtime]
max_duration = "30m"

Full-Stack Development

# .coi/profiles/fullstack/config.toml
forward_env = ["GITHUB_TOKEN", "DATABASE_URL"]

[container]
image = "coi-default"
persistent = true

[environment]
NODE_ENV = "development"

[tool]
name = "claude"
permission_mode = "bypass"

[[mounts]]
host = "~/.npm"
container = "/home/code/.npm"

[limits.cpu]
count = "4"

[limits.memory]
limit = "8GiB"

Restricted Research

# .coi/profiles/research/config.toml
context = "CONTEXT.md"

[tool]
name = "claude"
permission_mode = "interactive"

[network]
mode = "allowlist"
allowed_domains = ["github.com", "stackoverflow.com", "docs.python.org"]

[limits.runtime]
max_duration = "1h"

[limits.memory]
limit = "2GiB"

Validation

  • Profile directories without a config.toml are silently skipped
  • Invalid TOML in a profile config.toml causes a fatal error at config load time
  • Missing context files are validated at --profile usage time (not at config load) — this allows profiles to be loaded from all sources without requiring every referenced file to exist
  • Missing build scripts are validated at --profile usage time
  • coi profile info displays all resolved paths (build scripts, context files) so you can verify they're correct
⚠️ **GitHub.com Fallback** ⚠️