| title | Configuration Reference |
|---|
Loop is configured via HJSON files (JSON with comments and trailing commas). This document covers all configuration fields, their defaults, and the merge rules for project-level overrides.
Config files can be edited directly on disk, or through the HTTP API (GET/PUT /api/config for global, GET/PUT /api/config/project for per-project). The desktop app Settings panel uses the API to provide a schema-driven form editor and a raw JSON editor. See API Reference: Configuration for endpoint details.
See also: Docker Container Lifecycle, Task Scheduling.
Location: ~/.loop/config.json
This is the primary configuration file, loaded at startup. All paths below are relative to the ~/.loop/ directory (referred to as loopDir).
| Field | Type | Default | Description |
|---|---|---|---|
platforms |
string[] |
(required) | One or more of "discord", "slack", "local". Multiple platforms can run simultaneously. |
discord_token |
string |
"" |
Discord bot token. Required when "discord" is in platforms. |
discord_app_id |
string |
"" |
Discord application ID. Required when "discord" is in platforms. |
discord_guild_id |
string |
"" |
Discord guild (server) ID. Enables auto-creation of Discord channels via loop mcp --dir. |
slack_bot_token |
string |
"" |
Slack bot token (xoxb-...). Required when "slack" is in platforms. |
slack_app_token |
string |
"" |
Slack app-level token (xapp-...). Required when "slack" is in platforms. |
The "local" platform requires no external credentials -- it runs with the Electron desktop app as the UI.
Exactly one of these should be set. OAuth takes precedence if both are provided.
| Field | Type | Default | Description |
|---|---|---|---|
claude_code_oauth_token |
string |
"" |
OAuth token from claude setup-token. Uses your Claude subscription. |
anthropic_api_key |
string |
"" |
Anthropic API key. Uses pay-per-token API pricing. |
| Field | Type | Default | Description |
|---|---|---|---|
claude_bin_path |
string |
"claude" |
Path to the Claude CLI binary inside containers. |
claude_model |
string |
"claude-sonnet-5" |
Claude model to use. Options: "claude-opus-5", "claude-sonnet-5", "claude-fable-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6[1m]", "claude-opus-4-6", "claude-sonnet-4-6". |
claude_effort |
string |
"" |
Reasoning-effort level passed to the Claude CLI as --effort. Empty leaves it unset (model default). Options: "low", "medium", "high", "xhigh", "max". |
Both claude_model and claude_effort can additionally be overridden per channel/thread/worktree on demand from the chat composer's model pill (persisted on the channel row, applied from the next run) — see Chat: Model / Effort Override. The per-channel override beats the merged config value.
| claude_batch_disallowed_tools | []string | ["ScheduleWakeup", "CronCreate", "CronDelete", "CronList", "Monitor", "ReportFindings"] | Claude Code tool names passed to --disallowedTools for batch (--print) agent runs only — the interactive terminal is unaffected. The defaults rely on a persistent harness that one-shot containers lack: ScheduleWakeup/Cron* schedule re-invocations that never fire, and Monitor arms a background watcher whose cross-turn events are dropped when the container exits at end of turn — so they silently park work that never resumes. ReportFindings reports code-review findings into a harness UI channel batch runs don't render. Review runs are the exception: they drop ReportFindings from this list, because the built-in /code-review command only runs inline (rather than forking a silent subagent) when it can call that tool, and the daemon reads the findings off the stream. Override to tune the list (e.g. to re-enable a tool). |
| claude_retry | object | see below | Automatic recovery from Claude API limits on batch agent runs. Keys: max_attempts (5) extra attempts after the first failure (0 disables), backoff_base_sec (5) first-retry delay that doubles each attempt, backoff_max_sec (120) per-attempt delay ceiling — these govern in-process exponential backoff for transient errors (rate limiting, overload, 5xx). session_limit_auto_continue (true) handles a session limit error (You've hit your session limit · resets <time>) by scheduling a one-shot retry at the announced reset time that resumes the session with continue; it is persisted via the scheduler so it survives daemon restarts. Terminal errors (usage/quota exhaustion, auth, billing) are never retried. |
| keep_mcp_configs | bool | false | When true, preserves MCP config JSON files after container runs. Useful for debugging MCP server configuration. |
| Field | Type | Default | Description |
|---|---|---|---|
db_path |
string |
"~/.loop/loop.db" |
SQLite database path. |
log_file |
string |
"~/.loop/loop.log" |
Log file path. |
log_level |
string |
"info" |
Log level (debug, info, warn, error). |
log_format |
string |
"text" |
Log format (text or json). |
| Field | Type | Default | Description |
|---|---|---|---|
container_image |
string |
"loop-agent:latest" |
Docker image for agent containers. |
container_timeout_sec |
int |
3600 |
Maximum execution time per container run (seconds). |
container_memory_mb |
int |
1024 |
Memory limit per container (MB). |
container_cpus |
float |
1.0 |
CPU limit per container (fractional cores). |
container_keep_alive_sec |
int |
300 |
Seconds to keep a finished container before removal (for docker logs debugging). |
| Field | Type | Default | Description |
|---|---|---|---|
Browser settings are grouped under "browser": |
| Field | Type | Default | Description |
|---|---|---|---|
browser.enabled |
bool |
true |
Enable Chrome browser automation. When disabled, no Chrome container is started and the loop-browser MCP server is not registered. |
browser.chrome_image |
string |
"loop-chrome:latest" |
Docker image for Chrome sidecar containers. |
browser.host_cdp_port |
int |
9222 |
CDP port for Host mode. Requires chrome://inspect/#remote-debugging enabled in Chrome. |
| Field | Type | Default | Description |
|---|---|---|---|
api_addr |
string |
":8222" |
HTTP API listen address. |
poll_interval_sec |
int |
30 |
How often the scheduler checks for due tasks (seconds). |
| Field | Type | Default | Description |
|---|---|---|---|
github.gh_user |
string |
"" |
Named gh CLI account to use for PR lookups (see gh auth status). Empty falls back to whichever account gh currently has active. Used by GET /api/channels/{id}/pr and the Git panel's PR chip. |
Format: host_path:container_path[:mode]
~is expanded to the host user's home directory on both sides.- Named volumes (no
/,~, or.prefix) are passed to Docker directly. - Non-existent host paths are silently skipped.
- See Containers: Mount Processing for full details.
"copy_files": ["~/.claude.json"]| Field | Type | Default | Description |
|---|---|---|---|
copy_files |
string[] |
[] |
Extra files copied into each container (not mounted); each gets its own copy. ~/.claude.json is always copied automatically (flag-merged) regardless of this list — see Containers — File Copying. |
"envs": {
"BASH_ENV": "~/.bashrc",
"ENABLE_LSP_TOOL": false
}| Field | Type | Default | Description |
|---|---|---|---|
envs |
map[string]any |
null |
Extra environment variables passed to all containers. Values can be any JSON type; non-strings are stringified. ~ in values is expanded. |
"mcp": {
"servers": {
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token"
}
}
}
}Each server entry has:
| Field | Type | Description |
|---|---|---|
command |
string |
Executable to run the MCP server. |
args |
string[] |
Command-line arguments. |
env |
map[string]string |
Environment variables for the server process. |
A built-in "loop" server is always added unless the user defines one with the same name. See Containers: MCP Config Generation.
"task_templates": [
{
"name": "daily-summary",
"description": "Generate a daily summary",
"schedule": "0 17 * * *",
"type": "cron",
"prompt": "Summarize today's activity",
"auto_delete_sec": 60
},
{
"name": "heartbeat",
"description": "Periodic health check",
"schedule": "30m",
"type": "interval",
"prompt_path": "heartbeat.md"
}
]| Field | Type | Description |
|---|---|---|
name |
string |
Unique template identifier. |
description |
string |
Human-readable description shown in template listings. |
schedule |
string |
Cron expression, Go duration, or RFC3339 timestamp (depends on type). |
type |
string |
One of "cron", "interval", "once", "manual" (manual tasks have no schedule and only run on demand). |
prompt |
string |
Inline prompt text. Mutually exclusive with prompt_path. |
prompt_path |
string |
Path to a prompt file, resolved as ~/.loop/templates/{prompt_path}. Mutually exclusive with prompt. |
origin_branch |
string |
Base branch for worktree tasks. If omitted, auto-detected from the parent repo on first run. |
update_before_run |
bool |
When true, prepends git fetch/rebase instructions to the prompt before each run. Default: false. |
auto_delete_sec |
int |
Seconds after execution to auto-delete the task's thread. 0 disables. |
See Task Scheduling for full details.
"prompt_shortcuts": [
{
"name": "coverage",
"description": "Run coverage check",
"prompt": "Run make coverage-check and report results"
},
{
"name": "review",
"description": "Review uncommitted and branch changes",
"prompt_path": "review-code.md"
}
]| Field | Type | Description |
|---|---|---|
name |
string |
Unique shortcut identifier. Shown in the # picker in chat. |
description |
string |
Human-readable description shown below the name. |
prompt |
string |
Inline prompt text. Mutually exclusive with prompt_path. |
prompt_path |
string |
Path to a prompt file, resolved as ~/.loop/shortcuts/{prompt_path} (global) or .loop/shortcuts/{prompt_path} (project). Mutually exclusive with prompt. |
Shortcuts appear in the chat input when the user types #. Selecting a shortcut sends its resolved prompt as a message. The API endpoint GET /api/shortcuts returns all shortcuts with resolved prompts; pass ?channel_id=<id> to merge project-level shortcuts. Agents can manage shortcuts via the prompt_shortcut MCP tool or the POST /api/shortcuts endpoint — add, update, or delete shortcuts in either global or project scope.
"bash_shortcuts": [
{
"name": "make lint",
"description": "Run the linter",
"command": "make lint"
},
{
"name": "tests",
"description": "Run unit tests",
"command_path": "run-tests.sh"
}
]| Field | Type | Description |
|---|---|---|
name |
string |
Unique shortcut identifier. Shown in the $ picker on terminal panes. |
description |
string |
Human-readable description shown below the name. |
command |
string |
Inline shell command. Mutually exclusive with command_path. |
command_path |
string |
Path to a script file, resolved as ~/.loop/bash-shortcuts/{command_path} (global) or .loop/bash-shortcuts/{command_path} (project). The file contents become the command body. Mutually exclusive with command. |
Shortcuts appear in the terminal footer $ picker. They mount on Docker Shell and Host Shell panes (raw bash, sent with a trailing newline) and on Docker Agent panes (Claude TUI, sent as a bracketed paste + \r so multi-line scripts arrive as a single paste buffer). The # prompt picker and the $ bash picker are mutually exclusive on a given pane — Docker Agent panes show #, raw-shell panes show $. The API endpoint GET /api/bash-shortcuts returns shortcuts with resolved command text; pass ?channel_id=<id> to merge project-level shortcuts. Agents can manage them via the bash_shortcut MCP tool or the POST /api/bash-shortcuts endpoint.
Enables and configures the Review panel (see review.md).
"review": {
"enabled": true,
"prompt_path": "review.md"
}| Field | Type | Description |
|---|---|---|
enabled |
bool |
Gates the Review panel. false (the default) hides the panel in the FE picker and makes the backend reject /review/* requests with 403. Layered per-global/per-project/per-worktree like github.gh_user. |
prompt |
string |
Inline prompt text. Mutually exclusive with prompt_path. |
prompt_path |
string |
Path to a prompt file, resolved as ~/.loop/review/{prompt_path}. Mutually exclusive with prompt. |
Both prompt fields empty (the default) uses the daemon's built-in default prompt: the bare /code-review slash command, with the output contract (report actionable findings via the built-in ReportFindings tool, each carrying a file and a 1-based line) carried in the system prompt. A configured non-slash prompt gets no system prompt, so it must carry its own contract — see review.md.
Gates the public playground-share feature — exposing a playground over the internet through a cloudflared quick tunnel.
"playground_share": {
"enabled": true
}| Field | Type | Description |
|---|---|---|
enabled |
bool |
Gates the feature. false (the default) makes the share endpoints and the playground_share MCP tool return 403/error, and hides the Share toggle in the FE. Layered per-global/per-project like review.enabled. Also toggleable from Settings → Playground Share (the config schema surfaces it as a form field). |
When enabled, sharing a playground starts a cloudflared quick tunnel (anonymous — no Cloudflare account) that points at a dedicated, playground-only listener; the main API is never exposed. The cloudflared binary is downloaded lazily to ~/.loop/bin on first use and verified against a pinned sha256. Each share gets a unique opaque URL (https://<random>.trycloudflare.com/p/<token>), idempotent per playground; revoking it (Unshare, or the global Playground Shares panel) returns 404 immediately. Multiple playgrounds share in parallel over one tunnel, which stops when the last share is removed. See playground.md.
"workflows": [
{
"name": "fix-issue",
"description": "Analyze an issue, plan, implement, and create a PR",
"inputs": {
"issue_url": { "description": "Issue URL", "required": true }
},
"nodes": [
{ "id": "analyze", "type": "bash", "script": "gh issue view {{.Inputs.issue_url}} --json title,body,labels" },
{ "id": "plan", "type": "prompt", "depends_on": ["analyze"], "prompt": "Create a plan:\n\n{{.NodeOutputs.analyze}}" },
{ "id": "implement", "type": "prompt", "depends_on": ["plan"], "prompt": "Implement:\n\n{{.NodeOutputs.plan}}" },
{ "id": "pr", "type": "prompt", "depends_on": ["implement"], "prompt": "Commit and create a PR" }
]
}
]| Field | Type | Description |
|---|---|---|
name |
string |
Unique workflow identifier. |
description |
string |
Human-readable description. |
timeout |
string |
Go duration (e.g. "30m") that caps total DAG execution time. Run fails with "workflow timeout exceeded" on expiry. |
inputs |
map[string]WorkflowInput |
Named inputs with description, required, and default fields. |
nodes |
NodeDef[] |
Ordered list of DAG nodes. |
Node fields:
| Field | Type | Description |
|---|---|---|
id |
string |
Unique node identifier within the workflow. |
type |
string |
"prompt", "bash", "loop", or "approval". |
depends_on |
string[] |
IDs of nodes that must complete before this one starts. |
prompt |
string |
Prompt text for prompt/loop nodes. Supports Go text/template. Mutually exclusive with prompt_path. |
prompt_path |
string |
Path to a prompt file, resolved as {loopDir}/workflows/{prompt_path}. Mutually exclusive with prompt. |
system_prompt |
string |
Optional system prompt for prompt nodes. Supports templates. |
script |
string |
Shell command(s) for bash nodes, passed to /bin/sh -c. Any sh-compatible content — one-liners, multi-line scripts, pipelines, heredocs. To run a script file on disk, just invoke it (e.g. bash workflows/build.sh); the bash container shares the same mounts as agent containers. Supports templates. |
max_iterations |
int |
Max iterations for loop nodes (default: 10). |
condition |
string |
Template for loop nodes; stops when it renders "true". |
body |
NodeDef[] |
Optional child nodes for loop nodes. When set, each iteration runs the body sequentially instead of re-running the loop node's own prompt. Children may be prompt or bash only — nested loop and approval are rejected by the validator. Mutually exclusive with prompt/prompt_path on the loop node itself. |
message |
string |
Approval message for approval nodes. Supports templates. |
timeout |
string |
Go duration (e.g. "5m", "1h"). For approval nodes: deadline for human response. For prompt/bash/loop nodes: enforced execution deadline via context cancellation. |
retry |
RetryConfig |
Optional retry with max_retries, backoff_base, backoff_max. |
when |
string |
Template that must evaluate to "true" for the node to run. Skipped otherwise. |
trigger_rule |
string |
"all_success" (default), "all_done", or "one_success". Controls how dependency failures affect this node. |
See Workflows for architecture details and the DAG execution model.
"workflow_concurrency": {
"max_concurrent_runs": 5,
"max_concurrent_nodes": 10
}| Field | Type | Description |
|---|---|---|
max_concurrent_runs |
int |
Maximum workflow runs executing in parallel. 0 = unlimited. Default: 0. |
max_concurrent_nodes |
int |
Maximum node goroutines across all active runs. 0 = unlimited. Default: 0. |
Available at both global and project level. Project values override global values when > 0.
"memory": {
"enabled": true,
"paths": ["./memory", "!./memory/plans"],
"max_chunk_chars": 5000,
"reindex_interval_sec": 300,
"embeddings": {
"provider": "ollama",
"model": "nomic-embed-text",
"ollama_url": "http://localhost:11434"
}
}| Field | Type | Default | Description |
|---|---|---|---|
memory.enabled |
bool |
false |
Must be explicitly true to enable semantic memory search. |
memory.paths |
string[] |
["./memory"] |
Directories to index. Paths can be absolute or relative to the project work dir. Prefix with ! to exclude. |
memory.max_chunk_chars |
int |
5000 |
Maximum characters per embedding chunk. |
memory.reindex_interval_sec |
int |
300 |
Periodic re-index interval in seconds (5 minutes default). |
memory.embeddings.provider |
string |
"" |
Embedding provider. Currently only "ollama" is supported. |
memory.embeddings.model |
string |
"" |
Embedding model name (e.g. "nomic-embed-text"). |
memory.embeddings.ollama_url |
string |
"http://localhost:11434" |
Ollama API endpoint. |
"quality": {
"max_files": 25000,
"exclude_paths": ["docs/**"],
"complexity": {
"cyclomatic_t": 10,
"cognitive_t": 15,
"nesting_t": 4,
"params_t": 5,
"loc_t": 60
},
"clones": {
"min_loc": 5,
"max_distance": 3
},
"rules": {
"signal_floor": { "enabled": true, "threshold": 5000 },
"parse_fail": { "enabled": true, "threshold": 0.01 },
"no_import_cycles": { "enabled": true },
"complexity_ceiling": { "enabled": true, "threshold": 10 },
"complexity_score_floor": { "enabled": true, "threshold": 0.5 },
"duplication_ceiling": { "enabled": true, "threshold": 0.10 }
}
}Scans are manual: panel "Scan now" button, loop quality scan, or the quality_scan MCP tool.
| Field | Type | Default | Description |
|---|---|---|---|
quality.max_files |
int |
25000 |
Hard cap on scannable files after exclusions. Over the cap returns a structured RepoTooLarge error; no partial scan is produced. |
quality.exclude_paths |
string[] |
[] |
Doublestar globs appended to the built-in defaults (.git/, node_modules/, dist/, build/, target/, vendor/, *.min.js, *.generated.go) and the repo's .gitignore. |
quality.complexity.cyclomatic_t |
int |
10 |
Soft threshold for McCabe cyclomatic complexity. Above T the dimension scores T/raw (0.50 at 2T, 0.10 at 10T, asymptotic to 0). 0 disables the dimension. |
quality.complexity.cognitive_t |
int |
15 |
Soft threshold for Sonar cognitive load. Same T/raw curve. |
quality.complexity.nesting_t |
int |
4 |
Soft threshold for max nesting depth. |
quality.complexity.params_t |
int |
5 |
Soft threshold for parameter count. |
quality.complexity.loc_t |
int |
60 |
Soft threshold for function LOC. |
quality.clones.min_loc |
int |
5 |
Minimum function LOC to be considered for clone clustering. Below this, functions are skipped to avoid trivial getter/setter clusters. |
quality.clones.max_distance |
int |
3 |
Max SimHash Hamming distance between two functions in the same cluster. 0 requires exact-shape duplicates; 64 clusters anything. |
quality.rules.<name>.enabled |
bool |
true |
Per-rule on/off. Built-in rules: no_import_cycles, signal_floor, parse_fail, complexity_ceiling, complexity_score_floor, duplication_ceiling. |
quality.rules.signal_floor.threshold |
float |
5000 |
Lower bound on quality_signal (0–10000). Below this, the rule fails. |
quality.rules.parse_fail.threshold |
float |
0.01 |
Maximum fraction of files allowed to fail to parse (0.01 = 1%). |
quality.rules.complexity_ceiling.threshold |
int |
10 |
Maximum number of functions allowed to breach any complexity soft threshold before this rule fails. |
quality.rules.complexity_score_floor.threshold |
float |
0.5 |
Lower bound on the complexity metric's headline score (0–1). |
quality.rules.duplication_ceiling.threshold |
float |
0.10 |
Maximum tolerated duplicated_loc / total_loc ratio across clone clusters. |
Config changes drop the engine's parser/graph cache for the affected channel but do not auto-trigger a rescan — the panel keeps rendering the previous snapshot until the next manual or live scan rebuilds with the new config. See Quality for the full surface.
"permissions": {
"owners": { "users": ["U12345678"], "roles": ["1234567890123456789"] },
"members": { "users": [], "roles": [] }
}| Field | Type | Description |
|---|---|---|
permissions.owners.users |
string[] |
User IDs with owner access (full control including permission management). |
permissions.owners.roles |
string[] |
Role IDs with owner access. |
permissions.members.users |
string[] |
User IDs with member access (can trigger bot and manage tasks). |
permissions.members.roles |
string[] |
Role IDs with member access. |
If all config and DB permissions are empty, everyone is treated as an owner (bootstrap mode).
The gate runs a seccomp RET_USER_NOTIF filter inside each agent container and forwards approve-rule traps to Discord / Slack / the desktop. See HTTP API: Gate Approvals for the resolve endpoint and Events: gate.approval_requested for the WebSocket payload.
"gates": {
"rate_limits": { "pending": 30, "per_minute": 60, "total": 500 },
"audit": { "retention_days": 30, "verbose": false },
"agentgate": {
"enabled": true,
"default_decision": "allow",
"path_rules": [ /* unix-socket connect rules */ ],
"command_rules": [ /* execve/execveat rules */ ],
"file_rules": [ /* openat/renameat2/unlinkat rules */ ]
},
"docker_proxy": {
"enabled": true,
"default_decision": "approve",
"http_rules": [ /* per-method/path rules */ ],
"body_rules": [ /* JSON-body inspection for container-create etc. */ ]
}
}gates is the single umbrella for both enforcement layers — the kernel-level agentgate (seccomp) and the application-level docker_proxy (Docker HTTP). Shared resources (rate_limits, audit) live at the umbrella level because both layers feed into the same agentgate.Manager for approvals.
| Field | Type | Default | Description |
|---|---|---|---|
gates.rate_limits.pending |
int |
30 |
Max simultaneously-pending prompts per container; new requests over this auto-deny. Shared across both gate layers. |
gates.rate_limits.per_minute |
int |
60 |
Max distinct-target prompts per minute per container. |
gates.rate_limits.total |
int |
500 |
Per-container lifetime cap; exceeded = deny-by-default for the rest of the run. |
gates.audit.retention_days |
int |
30 |
Days of gate decision logs to retain on disk. FileAuditor prunes older agentgate-YYYY-MM-DD.jsonl files on each rotation; 0 or negative disables pruning. Surfaced into the container as LOOP_GATE_AUDIT_RETENTION_DAYS. |
gates.audit.verbose |
bool |
false |
When false (default), FileAuditor drops silent allows (policy said allow, nobody was prompted) and cache-hit allows — only denies (silent or prompted) and user-clicked decisions are logged, keeping the trail focused on events operators actually want to review. When true, every decision is logged, including silent allows and cache hits — use for debugging rule authoring or exporting a full trace to a SIEM. Surfaced into the container as LOOP_GATE_AUDIT_VERBOSE=1. |
gates.agentgate.enabled |
bool |
true |
Master switch for the seccomp gate. When false, no filter is installed and no gate policy file is bind-mounted. Transitively disables gates.docker_proxy unless explicitly overridden. |
gates.agentgate.default_decision |
string |
"allow" |
"allow" or "approve". Fallback when no rule matches. |
gates.agentgate.path_rules |
[]PathRule |
1 rule (docker.sock → approve) | Rules for unix-socket connects, matched by absolute socket path. User rules are appended to the shipped baseline. |
gates.agentgate.command_rules |
[]CommandRule |
2 rules (rm -rf / deny, git push etc. approve) | Rules for execve/execveat, matched by basename glob and argv regex. User rules are appended to the baseline. |
gates.agentgate.file_rules |
[]FileRule |
8 rules | Rules for file ops (openat, renameat2, unlinkat, linkat, symlinkat, fchmodat, fchownat, mkdirat), matched by doublestar path glob × operation set. User rules are appended. |
Rule field shapes:
// PathRule
{ "pattern": "/var/run/docker.sock", "decision": "approve", "message": "agent wants to use docker" }
// CommandRule — empty commands or args_patterns means "match any"
{ "commands": ["git"], "args_patterns": ["^(push|remote add|reset --hard) .*"], "decision": "approve", "message": "git write op" }
// FileRule — empty paths or operations means "match any"
{ "paths": ["**/.ssh/**"], "operations": ["read","write","create","delete","chmod"], "decision": "deny", "message": "credentials blocked" }decision is one of "allow", "deny", or "approve". approve blocks the syscall and broadcasts a gate.approval_requested event; the UI's three-button card resolves it via POST /api/gate/approvals/{id}.
| Field | Type | Default | Description |
|---|---|---|---|
gates.docker_proxy.enabled |
bool |
mirrors gates.agentgate.enabled |
Replaces the container's direct /var/run/docker.sock bind with an in-container HTTP proxy (loop dockerproxy) that listens on /var/run/docker.sock (tmpfs) and reverse-proxies to the real daemon socket at /var/run/docker.sock.host while enforcing http_rules + body_rules. |
gates.docker_proxy.default_decision |
string |
"approve" |
Fallback when no http_rules entry matches. "allow", "approve", or "deny". |
gates.docker_proxy.http_rules |
[]HTTPServiceRule |
15 rules | Method × path-regex rules. User rules are appended to the baseline (GET reads allowed; POST create/start/exec/update approved; swarm/secrets/plugins denied). |
gates.docker_proxy.body_rules |
[]BodyRule |
2 rules | JSON-body inspection rules on POST /containers/create and /update. Baseline denies root-level bind-mounts (/, /etc, /root, /proc, /sys, /dev, /var/run/docker.sock), Privileged, host-namespace flags, dangerous CapAdd, --security-opt=*unconfined, Devices/VolumesFrom, and MaskedPaths: []. Body-rule decisions follow the same semantics as http_rules: deny returns 403 with no prompt, approve blocks and prompts the user, allow passes through silently. |
"desktop": {
"theme": "dark",
"islands": true,
"preview_tabs": true,
"auto_save_on_blur": false,
"stop_daemon_on_quit": false,
"font_sizes": {
"sidebar": 12,
"chat": 13,
"terminal": 13,
"editor": 13,
"panels": 12
}
}| Field | Type | Default | Description |
|---|---|---|---|
desktop.theme |
string |
"dark" |
Color theme. One of "dark", "light", "claude". |
desktop.islands |
bool |
true |
Islands layout — panels float as rounded cards over a deep canvas with gaps between them. |
desktop.preview_tabs |
bool |
true |
Single-click opens files in a transient preview tab. Double-click promotes to permanent. |
desktop.auto_save_on_blur |
bool |
false |
Save open editor tabs when the window loses focus. |
desktop.stop_daemon_on_quit |
bool |
false |
Uninstalls the daemon service on quit. Re-installed on next app launch. |
desktop.font_sizes |
object |
See above | Per-area font size overrides (in px). Keys: sidebar, chat, terminal, editor, panels. |
These settings are global-only (not available in project configs). Changes are applied live when saving the config — no restart required.
Location: {workDir}/.loop/config.json
Project-level configs allow per-project overrides. They are loaded and merged with the global config whenever a container is created for a channel that has a custom work directory.
Not all global fields are available in project configs. The following fields can be set:
| Field | Merge Behavior |
|---|---|
mounts |
Replaces global mounts entirely. Relative host paths are resolved relative to workDir. |
copy_files |
Replaces global copy_files entirely when set. |
extra_dirs |
Replaces global value when set. In worktree configs the parent project's extra_dirs are unioned with the worktree's (deduped, parent first), so a worktree inherits the same extra roots as its parent channel. |
mcp.servers |
Merged with global servers. Project servers override global servers with the same name. |
envs |
Merged with global envs. Project values override global values with the same key. |
claude_model |
Overrides global value when set. |
claude_bin_path |
Overrides global value when set. |
claude_batch_disallowed_tools |
Overrides global value when set (non-empty). |
claude_code_oauth_token |
Overrides global auth entirely. Clears anthropic_api_key. |
anthropic_api_key |
Overrides global auth entirely. Clears claude_code_oauth_token. |
container_image |
Overrides global value when set. |
container_memory_mb |
Overrides global value when set. |
container_cpus |
Overrides global value when set. |
keep_mcp_configs |
Overrides global value when set. |
memory.paths |
Appended to global memory paths. |
memory.max_chunk_chars |
Overrides global value when set (> 0). |
memory.embeddings |
Overrides global embeddings config entirely when set. |
permissions |
Replaces global permissions entirely when set. |
task_templates |
Merged by name. Project templates override global templates with the same name; new names are appended. |
prompt_shortcuts |
Merged by name. Project shortcuts override global shortcuts with the same name; new names are appended. |
workflows |
Merged by name. Project workflows override global workflows with the same name; new names are appended. |
workflow_concurrency.max_concurrent_runs |
Overrides global value when > 0. |
workflow_concurrency.max_concurrent_nodes |
Overrides global value when > 0. |
browser.enabled |
Overrides global value when set. |
browser.chrome_image |
Overrides global value when set. |
browser.host_cdp_port |
Overrides global value when set. |
github.gh_user |
Overrides global value when set. |
review.enabled / review.prompt / review.prompt_path |
Each field overrides the global value only when explicitly set (see Review). |
gates.agentgate.enabled |
Narrows only: project may set false to disable the gate for this project; it cannot re-enable the gate when global gates.agentgate.enabled is false. Transitively disables gates.docker_proxy.enabled when the project turns the gate off. |
gates.agentgate.path_rules / command_rules / file_rules |
Prepended to the merged global rules (first-match-wins applies project rules first). Rules with decision: "allow" are rejected at load time — project configs may only tighten the policy, not loosen it. |
gates.agentgate.default_decision |
Ignored — global wins unconditionally. |
gates.docker_proxy.enabled |
Same narrow rule as gates.agentgate.enabled: project can disable (not re-enable). |
gates.docker_proxy.http_rules / body_rules |
Prepended to the merged global rules. Rules with decision: "allow" are rejected at load time. |
gates.docker_proxy.default_decision |
Ignored — global wins. |
gates.rate_limits / gates.audit |
Ignored — global wins unconditionally. |
The merge follows these principles:
- Replace: The project value completely replaces the global value (mounts, copy_files, permissions).
- Merge: Both global and project values are combined, with project taking precedence on conflicts (MCP servers, envs, task templates, workflows).
- Append: Project values are added to the global list (memory paths).
- Override: A single scalar value replaces the global one (claude_model, container_image, etc.).
- Narrow merge: Security-sensitive fields under
gates(agentgate,docker_proxy) have a locked-down merge: project rules prepend,allowrules are rejected at load, anddefault_decision/rate_limits/auditare ignored so a compromised project file cannot loosen global policy. - Absent = inherit: If a field is not set in the project config, the global value is used unchanged.
{
// Required: platform(s) to run
"platforms": ["local"],
// Discord credentials (required when platform is "discord")
"discord_token": "your-discord-bot-token-here",
"discord_app_id": "your-discord-app-id-here",
"discord_guild_id": "your-discord-guild-id-here",
// Slack credentials (required when platform is "slack")
//"slack_bot_token": "xoxb-your-slack-bot-token",
//"slack_app_token": "xapp-your-slack-app-token",
// Authentication (use one; OAuth takes precedence)
//"claude_code_oauth_token": "sk-ant-your-oauth-token-here",
//"anthropic_api_key": "sk-ant-your-api-key-here",
// Storage & logging
//"db_path": "~/.loop/loop.db",
//"log_file": "~/.loop/loop.log",
//"log_level": "info",
//"log_format": "text",
// Container settings
//"container_image": "loop-agent:latest",
//"container_timeout_sec": 3600,
//"container_memory_mb": 1024,
//"container_cpus": 1.0,
//"container_keep_alive_sec": 300,
// Scheduling & networking
//"poll_interval_sec": 30,
//"api_addr": ":8222",
// Agent settings
//"claude_model": "",
//"claude_bin_path": "claude",
//"keep_mcp_configs": false, // preserve MCP config files after container runs for debugging
// Browser automation
//"browser": {
// "enabled": true,
// "chrome_image": "loop-chrome:latest",
// "host_cdp_port": 9222
//},
// GitHub integration (used by the Git panel's PR chip)
//"github": {
// "gh_user": "" // named gh CLI account; empty = gh's currently-active account
//},
// RBAC permissions
//"permissions": {
// "owners": { "users": ["U12345678"], "roles": ["1234567890123456789"] },
// "members": { "users": [], "roles": [] }
//},
// Semantic memory search
"memory": {
"enabled": true,
"paths": ["./memory", "!./memory/plans"],
//"max_chunk_chars": 5000,
//"reindex_interval_sec": 300,
"embeddings": {
"provider": "ollama",
"model": "nomic-embed-text"
//"ollama_url": "http://localhost:11434"
}
},
// MCP servers
"mcp": {
//"servers": {
// "github": {
// "command": "npx",
// "args": ["-y", "@modelcontextprotocol/server-github"],
// "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token" }
// }
//}
},
// Custom environment variables for containers
"envs": {
"BASH_ENV": "~/.bashrc",
"ENABLE_LSP_TOOL": false
},
// Files copied into containers (not mounted)
"copy_files": ["~/.claude.json"],
// Container mounts
"mounts": [
"~/.claude:~/.claude",
"~/.gitconfig:~/.gitconfig:ro",
"~/.ssh:~/.ssh:ro",
"~/.aws:~/.aws",
"/var/run/docker.sock:/var/run/docker.sock",
"~/.loop/.bashrc:~/.bashrc:ro",
"loop-npmcache:~/.npm",
"loop-uvcache:~/.local/share/uv",
"loop-cache:~/.cache",
"loop-gocache:/go",
"loop-ollama:~/.ollama"
],
// Task templates
"task_templates": [
{
"name": "daily-summary",
"description": "Generate a daily summary of completed tickets",
"schedule": "0 17 * * *",
"type": "cron",
"prompt": "Generate a summary of all tickets closed today."
},
{
"name": "heartbeat",
"description": "Periodic health check",
"schedule": "30m",
"type": "interval",
"prompt_path": "heartbeat.md",
"auto_delete_sec": 60
}
],
// Prompt shortcuts (triggered via # in chat)
"prompt_shortcuts": [
{
"name": "coverage",
"description": "Run coverage check",
"prompt": "Run make coverage-check and report the results"
},
{
"name": "review",
"description": "Review uncommitted and branch changes",
"prompt_path": "review-code.md"
}
],
// Workflows — declarative DAG pipelines
"workflows": [],
// Workflow concurrency limits (0 = unlimited)
"workflow_concurrency": {
"max_concurrent_runs": 0,
"max_concurrent_nodes": 0
},
// Security gates umbrella. Both layers enabled by default with a baseline
// deny-list; uncomment to extend. User rules append to the shipped baseline.
//"gates": {
// //"rate_limits": { "pending": 30, "per_minute": 60, "total": 500 },
// //"audit": { "retention_days": 30, "verbose": false },
//
// // Kernel-level seccomp gate.
// //"agentgate": {
// // "enabled": true,
// // "default_decision": "allow",
// // //"path_rules": [ { "pattern": "/custom/socket", "decision": "deny" } ],
// // //"command_rules": [ { "commands": ["curl"], "args_patterns": ["evil\\.com"], "decision": "deny" } ],
// // //"file_rules": [ { "paths": ["/project-secret/**"], "operations": ["read","write"], "decision": "deny" } ]
// //},
//
// // Docker HTTP proxy (enabled automatically when agentgate.enabled). Replaces the
// // direct docker.sock bind with an in-container proxy that enforces per-method
// // and body-level rules.
// //"docker_proxy": {
// // "enabled": true,
// // "default_decision": "approve"
// // //"http_rules": [ { "methods": ["POST"], "paths": ["^/my/route$"], "decision": "deny" } ]
// //}
//}
}{
// Claude model override for this project
//"claude_model": "claude-opus-4-6",
// Claude binary path override
//"claude_bin_path": "/usr/local/bin/claude",
// Auth override (overrides global auth entirely)
//"claude_code_oauth_token": "sk-ant-project-oauth-token",
//"anthropic_api_key": "sk-ant-project-api-key",
// Container overrides
//"container_image": "loop-agent:latest",
//"container_memory_mb": 2048,
//"container_cpus": 2.0,
// Preserve MCP config files after container runs for debugging
//"keep_mcp_configs": false,
// Browser automation override
//"browser": {
// "enabled": false,
// "chrome_image": "loop-chrome:latest"
//},
// GitHub override (per-project gh CLI account)
//"github": {
// "gh_user": ""
//},
// Memory config (paths appended to global; embeddings override global)
//"memory": {
// "paths": ["./docs/architecture.md", "!./docs/wip"],
// "max_chunk_chars": 5000,
// "embeddings": { "provider": "ollama", "model": "nomic-embed-text" }
//},
// Files copied into containers (replaces global when set)
//"copy_files": ["~/.claude.json"],
// Project-specific MCP servers (merged with global; project overrides by name)
//"mcp": {
// "servers": {
// "my-tool": {
// "command": "/path/to/binary",
// "args": ["--flag"],
// "env": { "API_KEY": "secret" }
// }
// }
//},
// Extra env vars (merged with global; project overrides by key)
//"envs": {},
// Project mounts (replaces global mounts; relative paths resolved to project dir)
//"mounts": [
// "~/.claude:~/.claude",
// "~/.gitconfig:~/.gitconfig:ro",
// "~/.ssh:~/.ssh:ro"
//],
// Permissions override (replaces global permissions when set)
//"permissions": {
// "owners": { "users": [], "roles": [] },
// "members": { "users": [], "roles": [] }
//},
// Task templates (merged by name; project overrides global templates with same name)
//"task_templates": [
// {
// "name": "daily-summary",
// "description": "Project-specific daily summary",
// "schedule": "0 18 * * *",
// "type": "cron",
// "prompt": "Summarize today's project activity"
// }
//],
// Prompt shortcuts (merged by name; project overrides global shortcuts with same name)
//"prompt_shortcuts": [
// {
// "name": "lint",
// "description": "Run linter",
// "prompt": "Run make lint and fix any issues"
// }
//],
// Workflows (merged by name; project overrides global workflows with same name)
//"workflows": [
// {
// "name": "code-review",
// "description": "Review branch changes",
// "nodes": [
// { "id": "diff", "type": "bash", "script": "git diff main...HEAD" },
// { "id": "review", "type": "prompt", "depends_on": ["diff"], "prompt": "Review:\n\n{{.NodeOutputs.diff}}" }
// ]
// }
//],
// Workflow concurrency limits (overrides global values)
//"workflow_concurrency": {
// "max_concurrent_runs": 5,
// "max_concurrent_nodes": 10
//},
// Security gates override for this project only. Can disable (not re-enable);
// rules prepend to global and must use decision: "deny" or "approve"
// ("allow" is rejected at load time).
//"gates": {
// //"agentgate": {
// // "enabled": false,
// // //"command_rules": [ { "commands": ["npm"], "args_patterns": ["^publish"], "decision": "deny" } ],
// // //"file_rules": [ { "paths": ["./secret-vault/**"], "operations": ["read"], "decision": "deny" } ]
// //},
//
// //"docker_proxy": {
// // //"http_rules": [ /* project-specific Docker API restrictions */ ]
// //}
//}
}