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 .
Global Config
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).
All Fields
Platforms & Credentials
| 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.
Authentication
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. |
anthropic_base_url | string | "" | Send Claude’s API calls to a gateway or proxy instead of Anthropic’s API; passed to agents as ANTHROPIC_BASE_URL. A localhost or 127.0.0.1 URL is rewritten to host.docker.internal, so a gateway running on the host is reachable from the container. Settings → Authentication → API Base URL. |
Claude & Agent
| Field | Type | Default | Description |
|---|---|---|---|
claude_bin_path | string | "claude" | Path to the Claude CLI binary inside containers. |
claude_model | string | "claude-sonnet-5-5" | Claude model to use. Options: "claude-opus-5-5", "claude-opus-5", "claude-sonnet-5-5", "claude-sonnet-5", "claude-fable-5-1", "claude-fable-5", "claude-opus-4-8", "claude-haiku-5-5". Append [1m] (e.g. "claude-opus-5-5[1m]") for the 1M-token context window; Loop then sets CLAUDE_CODE_DISABLE_1M_CONTEXT=0 for the run so a host ~/.claude/settings.json that disables it can’t cap it at 200k. |
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". |
claude_prompt_suggestions | bool | true | After each chat turn, run the Claude CLI with --prompt-suggestions and offer its predicted next prompt in the chat composer, where Tab accepts it — see Chat: Prompt suggestions
. Each suggestion is one small extra request, and the run ends when it arrives. Only app chats ask for one: scheduled tasks, reviews, learn and explain runs, and Discord or Slack channels don’t. |
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. Subagents a run spawns use the same model unless the Agent call or the agent definition names another: Loop passes CLAUDE_CODE_SUBAGENT_MODEL=inherit through --settings, so a value in ~/.claude/settings.json (which agent containers mount) does not apply.
| 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, so they silently park work that never resumes; Monitor keeps the run, and so the channel, busy for as long as the watch lasts — Loop’s own monitor
MCP tool waits outside the run instead. A live session
drops Monitor from the list, since its process outlives the turn. 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. |
Storage & Logging
| 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). |
Container Settings
| 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 | 2048 | Memory limit per container (MB); 0 for no cap. Builds and test runs under the agent are what reach it. |
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). |
live_sessions | bool | true | Run plain chat turns in a live session
: one long-lived Claude process per channel that reads each turn on its input, so background tasks and the Monitor tool outlive a turn. false runs every turn in a container of its own, as a one-shot --print run. Learn, Explain, reviews, plugin and read-only runs, workflow steps and scheduled tasks are always one-shot. |
live_session_platforms | []string | [] | Platforms whose channels get live sessions ("local", "discord", "slack"). Empty means all. |
live_session_idle_sec | int | 600 | Stop a live session after this many seconds without a turn or background task; the next message starts it again on the same session. 0 never stops it. |
Browser Automation
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. The global value decides what the daemon builds at startup (providers, idle monitor, sidecar image); a project may set false to keep a browser from starting for its own channels, but cannot set true to get one back when the global value is false. |
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. Resolved per channel, but a port discovered from a Chrome already running for that channel wins over the configured one. |
browser.persist_profile | bool | true | Keep the agent’s cookies and logins across browser restarts, in a per-channel Docker volume. Docker mode only. |
browser.memory_mb | int | 2048 | Memory cap per Chrome sidecar (MB); 0 for no cap. Docker fixes the limit at container creation, so a change applies to the next sidecar, not a running one — delete the channel’s container to pick up a new value. Docker mode only. |
browser.cpus | float | 1.0 | CPU cap per Chrome sidecar, in cores; 0 for no cap. Rendering is bursty, so a starved sidecar shows up as a pane that stutters under input. Fixed at container creation, like memory_mb. Docker mode only. |
browser.extensions | []string | [] | Host directories holding unpacked Chrome extensions, loaded into every sidecar. Empty runs Chrome with --disable-extensions. Docker mode only. See Browser
. |
browser.cookie_import.source | string | "" | Browser profile to import cookies from, e.g. "chrome:Default". See Browser
. |
browser.cookie_import.domains | []string | [] | Cookie scopes to bring over, matched exactly against the scope shown in the picker. |
browser.cookie_import.auto | bool | false | Re-import source + domains into every newly started sidecar. Off by default: importing unattended is an explicit opt-in. |
Networking & Scheduling
| 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). |
GitHub
| 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. |
github.mount_gh_config | bool | false | Mounts the host’s ~/.config/gh into agent containers at the same path, so gh there uses your accounts. In Settings, turning it on adds ~/.config/gh:~/.config/gh to mounts (unless a mount at that path, or inside it, is already there) and turning it off removes the mounts there. Tokens gh keeps in the OS keychain don’t come along; only those in its hosts.yml do. A project override needs trust. |
GitLab
| Field | Type | Default | Description |
|---|---|---|---|
gitlab.glab_config_dir | string | "" | glab config dir for MR lookups, passed as GLAB_CONFIG_DIR. Each dir holds one account per host, so a second dir picks another account. Empty uses glab’s default. A project override needs trust. |
gitlab.mount_glab_config | bool | false | Mounts the host’s glab config dir (gitlab.glab_config_dir, else ~/Library/Application Support/glab-cli on macOS and ~/.config/glab-cli on Linux) into agent containers at ~/.config/glab-cli. In Settings, turning it on adds that mount to mounts (unless a mount at ~/.config/glab-cli, or inside it such as a config.yml file mount, is already there), turning it off removes the mounts there, and changing glab_config_dir while it is on updates the entry. A project override needs trust. |
A row’s branch shows its GitLab merge request instead of a GitHub PR when the repo’s origin points at a GitLab host: one whose name contains gitlab, or one listed in the glab config’s hosts. That needs glab on the daemon’s PATH.
Plugins
| Field | Type | Default | Description |
|---|---|---|---|
plugins.enabled | bool | false | Lets the daemon install and run plugins (loop plugin). Global only: a project config can’t turn it on. |
plugins.gate_binary | string | "" | A linux build of loop (make build-linux) that plugin containers run agentgate from. Empty downloads the release matching the daemon’s version; a dev build needs this set. |
plugins.daily_runs | integer | 50 | How many agent runs each plugin may start in 24 hours; past it, its run requests are refused (429). Global only. |
plugins.<id>.enabled | bool | false | Runs the installed plugin <id> in a project. Set globally for every project; a project config overrides it once trusted. See Plugins
. |
plugins.<id>.schedules.<action> | object | — | Asks for a scheduled task of the plugin’s action in each channel: enabled (default true), type (cron or interval) and schedule, both defaulting to the action’s default_schedule. A project’s entry replaces the global one once trusted. See Plugins
. |
Mounts
"mounts": [
"~/.claude:~/.claude",
"~/.gitconfig:~/.gitconfig:ro",
"~/.ssh:~/.ssh:ro",
"/var/run/docker.sock:/var/run/docker.sock",
"loop-npmcache:~/.npm", // Docker named volume
"loop-gocache:/go" // Docker named volume
]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
"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
. |
See Containers: File Copying .
Proxy
"proxies": {
"http_proxy": "http://127.0.0.1:3128",
"https_proxy": "http://127.0.0.1:3128",
"no_proxy": ["my-service", "my-cache", "*.internal"]
}| Field | Type | Default | Description |
|---|---|---|---|
proxies.http_proxy | string | "" | Proxy for http:// traffic in containers, image builds and the daemon’s own connections. Empty inherits the daemon’s own HTTP_PROXY/http_proxy. |
proxies.https_proxy | string | "" | Proxy for https:// traffic in containers, image builds and the daemon’s own connections. Empty inherits the daemon’s own HTTPS_PROXY/https_proxy. |
proxies.no_proxy | string[] | [] | Entries added to the container’s NO_PROXY (both letter cases) and to the proxies.noProxy Loop writes for the Docker CLI. Hostnames, suffixes or CIDRs. |
Set per variable and resolved per container: config wins where it is set, the daemon’s environment fills the rest. Localhost addresses are rewritten to host.docker.internal on the way in, so the value here is the one you would use on the host.
The daemon’s own connections to Discord, Slack and other services, and the gh and git fetch calls it makes for pull request lookups and reviews, resolve the same settings per connection, so the proxy can be switched on or off in config with no restart. daemon:start does not copy the shell’s proxy variables into the service, so config is the place to set it. See Daemon — Proxy
.
Prefer proxies.http_proxy/https_proxy over exporting the variables for the daemon. The daemon’s environment is fixed at loop serve start and a container’s is fixed at create, so a daemon started before the proxy was exported creates proxy-less containers until it restarts — and the containers it already created stay broken. Config is re-read per run. See Containers — Proxy Forwarding
.
no_proxy is additive rather than a replacement: Loop’s own required bypasses and the daemon’s own NO_PROXY are kept, and these entries are appended. It is only consulted when a proxy is configured. Name every container the agent reaches by hostname — NO_PROXY is matched against the hostname before DNS resolves it, so the default 172.16.0.0/12 entry covers a container dialled by IP but never one dialled as http://my-service:8080. See Containers — Proxy Forwarding
.
no_proxy_hosts, the key v2026.9.16 and v2026.9.17 shipped, is renamed to no_proxy by a filesystem migration on the first daemon start after the upgrade — in the global config and in the .loop/config.json of every project Loop has a channel for. The rename happens on the parsed document, so entries, ordering and comments survive it; a config carrying both keys has the old entries appended to the new list.
The three keys used to sit at the top level of the config. A later filesystem migration moves top-level http_proxy, https_proxy and no_proxy into the proxies block, in the same configs and keeping their comments. Where a proxies block already exists, its values win, keys it lacks are added, and the two no_proxy lists are joined.
Custom Environment Variables
"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
"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
"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
"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
"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 Chat Terminal 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 — Chat Terminal 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.
Chat Components
"chat_components": [
{
"name": "reaction",
"description": "A chemical reaction, balanced step by step",
"path": "reaction"
}
]| Field | Type | Description |
|---|---|---|
name | string | Template name the agent passes to the chat_component tool. Lowercase letters, digits, - and _. |
description | string | What the template is for, shown to the agent. |
path | string | Folder of the template’s files, under .loop/components/ (project) or ~/.loop/components/ (global). Defaults to name. |
A template is up to three files, each optional: shell.html, the markup the agent’s HTML goes into at its {{content}} slot; style.css; and guide.md, which tells the agent which classes and markup to use. A file is read from the project’s .loop/components/<path>/ first, then ~/.loop/components/<path>/, then from the built-in template of the same name. math (a squared math notebook page), canvas and react are built in; an entry with one of those names replaces it, keeping whichever built-in files it doesn’t supply. See Components
. The API endpoint GET /api/components lists the templates; pass ?channel_id=<id> to merge project-level ones.
Review
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. |
verify | bool | Run the verification step
once a review is over: the dedup pass, then a read-only agent that records a verdict on each agent comment and posts a summary to the channel. true by default. loop review:run --no-verify skips it for one run. |
verify_prompt | string | Replaces the verification agent’s built-in instructions. Loop still appends the PR, the reviewed commit, the checkout and the dedup pass’s outcome. |
max_iterations | int | Passes a review runs, 1–10 (default 3). The Review panel’s input starts at it, and run_review and POST /api/channels/{id}/review/start use it when given no max_iterations. |
update_base | bool | Before each pass a review fast-forwards the local base branch to origin’s when no worktree has it checked out (see Load
). It also fast-forwards it where it is checked out, when that worktree is clean, which moves that checkout’s files as a pull would; false leaves a checked-out base alone. true by default. |
Both prompt fields empty (the default) uses the daemon’s built-in default prompt: the /code-review slash command with the run’s effort as its level (e.g. /code-review high; bare when no effort is set), 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
.
PR watching
How Loop watches a thread’s PR/MR checks (see pr.md ).
"pr": {
"auto_fix": true,
"max_attempts": 3,
"merge_method": "squash",
"poll_sec": 30,
"idle_poll_sec": 300
}| Field | Type | Description |
|---|---|---|
auto_fix | bool | Send a failure on a commit the thread’s agent made back to that agent to fix. true by default. The Git panel’s Auto-fix toggle overrides it per thread. With it off, a failure lights the thread’s ci pill instead. |
max_attempts | int | Fixes a PR/MR gets before auto-fix stops and lights the ci pill (default 3). The count resets when the checks pass. |
merge_method | string | squash, merge or rebase: the method “merge when ready” and Merge now use when the repo allows it. Empty (the default) picks the first the repo allows, in that order. Any other value is ignored. |
poll_sec | int | Seconds between polls of a watched PR/MR (default 30). |
idle_poll_sec | int | Seconds between polls of an open PR/MR that isn’t watched (default 300), to catch a merge conflict with its moving base. |
Playground share
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
.
Learn
Configures the learn pass: after a chat run, a hidden forked session reviews it and proposes shortcuts, scheduled tasks, gate rules, mounts and a thread name or description for you to apply from the Learn view (see Chat: Learn from a run ).
"learn": {
"enabled": true,
"min_turns": 3,
"model": "",
"effort": "",
"prompt": "Also look for commands that needed an approval click."
}| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Default for the composer’s Learn switch. A channel that has been switched on or off keeps its own setting. |
min_turns | int | 3 | Runs with fewer turns than this don’t start a learn pass. |
model | string | "" | Model for the learn run. Empty uses the channel’s model override, else claude_model. |
effort | string | "" | Effort for the learn run. Empty uses the channel’s effort override, else claude_effort. |
prompt | string | "" | Extra instructions appended to the built-in learn prompt under “Additional instructions”. |
Layered global → project like review: each field overrides only when set in the project config. The Learn switch in the composer and the GET /api/channels/{id}/learn
default read the global → project → worktree merge for the channel’s dir; the daemon deciding whether to run a pass (and with what model, effort and prompt) reads the same merge its runs use: global → root checkout → worktree for a worktree thread, global → the channel’s dir otherwise. Also editable from Settings → Learn.
Where learn proposals are written
Applying a proposal of a config kind appends to the project config, .loop/config.json in the channel’s directory. For a worktree thread, or a thread under one, that’s the root checkout’s .loop/config.json, not the worktree’s. The file (and its .loop folder) is created when missing. The edit keeps the file’s comments, key order and formatting, and the write is atomic. Edits of one file are serialized, so proposals applied at once (Apply all) each land. Before you apply one, its card in the Learn pane shows the edit as a diff of the file.
| Kind | Written to | Notes |
|---|---|---|
prompt_shortcut | prompt_shortcuts | Fails if a prompt shortcut of that name already exists in the merged config. |
bash_shortcut | bash_shortcuts | Fails if a bash shortcut of that name already exists in the merged config. |
gate_rule | gates.agentgate.path_rules, command_rules or file_rules | Picked by the rule’s type (path, command, file). Project rules match before every global rule except the denies; that’s why a proposed rule must name what it matches (commands or args_patterns, paths, pattern) and can’t be a catch-all. A rule the merged config already has is left as it is, and the apply succeeds. |
mount | mounts | Fails if the merged config already has the exact mount (a relative host path is resolved against the project dir first, as project mounts are). Project mounts are added to the global ones, so only the new mount is written. |
scheduled_task, rename, description and ticket_url proposals don’t touch config: they create an enabled task in the channel and update the channel’s name, description or ticket URL, as POST /api/tasks, /rename, /description and /ticket do.
Explain
Configures explanations: a hidden forked session writes up a chat turn (what changed, commands and results, decisions, risks, how to review it) for the Explain pane (see Chat: Explain a turn
). Any turn can be explained from its Explain button whatever enabled says.
"explain": {
"enabled": false,
"model": "",
"effort": "",
"prompt": "Call out any change to the public API."
}| Field | Type | Default | Description |
|---|---|---|---|
enabled | bool | false | Default for the composer’s Explain switch, which explains every completed turn. A channel that has been switched on or off keeps its own setting. |
model | string | "" | Model for the explain run. Empty uses the channel’s model override, else claude_model. |
effort | string | "" | Effort for the explain run. Empty uses the channel’s effort override, else claude_effort. |
prompt | string | "" | Extra instructions appended to the built-in explain prompt under “Additional instructions”. |
Layered global → project like learn, and resolved for a channel the same way. Also editable from Settings → Explain.
Workflows
"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", "approval", or "plugin". |
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, bash or plugin 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. |
plugin | string | Plugin id for plugin nodes. |
action | string | Action id from the plugin’s manifest, for plugin nodes. Mutually exclusive with tool. |
tool | string | Tool name from the plugin’s manifest, for plugin nodes. Mutually exclusive with action. |
input | object | The tool’s input, for plugin nodes with a tool. Every string in it supports templates. |
timeout | string | Go duration (e.g. "5m", "1h"). For approval nodes: deadline for human response. For prompt/bash/loop/plugin 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
"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
"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
"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 (RBAC)
"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).
Security Gate
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": "allow",
"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 | 2 rules (/var/run/docker.sock.host deny, /var/run/docker.sock allow) | Rules for unix-socket connects, matched by absolute socket path. A non-empty global list replaces the shipped baseline. |
gates.agentgate.command_rules | []CommandRule | 2 rules (rm under /tmp allow, rm -rf on an absolute path deny) | Rules for execve/execveat, matched by basename glob and argv regex. A non-empty global list replaces the baseline. |
gates.agentgate.file_rules | []FileRule | 9 rules | Rules for file ops (openat, renameat2, unlinkat, linkat, symlinkat, fchmodat, fchownat, mkdirat), matched by doublestar path glob × operation set. A non-empty global list replaces the baseline. |
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. Containers the agent starts with the docker socket mounted get a second proxy socket instead of the daemon’s (see Nested docker socket
). |
gates.docker_proxy.default_decision | string | "allow" | Fallback when no http_rules entry matches. "allow", "approve", or "deny". |
gates.docker_proxy.http_rules | []HTTPServiceRule | 3 rules | Method × path-regex rules: exec and docker cp approve, swarm/nodes/secrets/configs/plugins deny; everything else falls through to default_decision. A non-empty global list replaces the baseline. |
gates.docker_proxy.body_rules | []BodyRule | 5 rules | JSON-body inspection on POST /containers/create, /containers/{id}/update and /volumes/create. The baseline denies bind sources under system paths, the docker socket and Docker Desktop’s VM paths (/host_mnt, /run/host-services), Privileged, host namespaces (PID, network, IPC, user, cgroup, UTS), CgroupParent, runtimes other than runc, CapAdd outside an allowlist (Docker’s defaults plus SYS_NICE, IPC_LOCK), *unconfined security opts, Devices/DeviceCgroupRules/VolumesFrom, and empty MaskedPaths/ReadonlyPaths; it asks before joining another container’s PID/IPC namespace or mounting a device-backed volume. Loop also appends a rule per container that asks before binds outside the agent’s own mounts (read-only binds of its read-only mounts pass). A non-empty global list replaces the baseline; the per-container rule is added either way. Keys match case-insensitively, like the daemon. Decisions follow http_rules: deny returns 403 with no prompt, approve blocks and prompts the user, allow passes through silently. See Default policy
for the full list, and Binds are pinned to the agent’s mounts
. |
Desktop (Electron App)
"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.
Project Config
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.
Available Fields
Not all global fields are available in project configs. The following fields can be set:
| Field | Merge Behavior |
|---|---|
mounts | Added to the global mounts. A project mount at the same container path as a global one replaces it (a trailing / is ignored). Relative host paths are resolved relative to workDir. |
inherit_mounts | Project only, default true. Set false to make the project’s mounts replace the global list instead of adding to it; with no mounts the project then gets none. |
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. |
proxies.http_proxy, proxies.https_proxy | Override the global value when set. Two proxy URLs cannot be combined, so a project behind its own proxy replaces the global one outright. |
proxies.no_proxy | Appended to the global list, so a project adds its own compose service names without losing the global ones. |
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. |
anthropic_base_url | Overrides global value when set, once the project config is trusted. Leaves the auth fields alone. |
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. |
chat_components | Merged by name. Project templates override global and built-in templates 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 | Narrows only: a project may set false to stop a browser starting for its channels. It cannot re-enable one when global browser.enabled is false — the providers and the loop-browser MCP server are only built at daemon startup. |
browser.chrome_image | Overrides global value when set. Read when the channel’s sidecar is created. |
browser.host_cdp_port | Overrides global value when set, unless a port was discovered from a Chrome already running for the channel. |
browser.persist_profile | Overrides global value when set. Read when the channel’s sidecar is created. |
browser.memory_mb | Overrides global value when set. Read when the channel’s sidecar is created. |
browser.cpus | Overrides global value when set. Read when the channel’s sidecar is created. |
browser.extensions | Replaces the global list when set (entries are not merged). Read when the channel’s sidecar is created. |
browser.cookie_import | Overrides per key: source and auto when set, domains replaces the global list when present. |
github.gh_user | Overrides global value when set. |
github.mount_gh_config | Overrides global value when set, once the project config is trusted. Set to false, it also drops the global mounts at ~/.config/gh. |
gitlab.glab_config_dir / gitlab.mount_glab_config | Each overrides the global value when set, once the project config is trusted. mount_glab_config: false also drops the global mounts at ~/.config/glab-cli. |
review.enabled / review.prompt / review.prompt_path / review.verify / review.verify_prompt / review.max_iterations / review.update_base | Each field overrides the global value only when explicitly set (see Review ). |
pr.auto_fix / pr.max_attempts / pr.merge_method / pr.poll_sec / pr.idle_poll_sec | Each field overrides the global value when set, once the project config is trusted: auto_fix when present, the counts when positive, merge_method when it’s a known value (see PR watching
). |
learn.enabled / learn.min_turns / learn.model / learn.effort / learn.prompt | Each field overrides the global value only when set: enabled and min_turns when present, the strings when non-empty (see Learn
). |
explain.enabled / explain.model / explain.effort / explain.prompt | Each field overrides the global value only when set: enabled when present, the strings when non-empty (see Explain
). |
gates.agentgate.enabled | Ignored: the global setting decides, so a project can neither switch the gate off nor on. |
gates.agentgate.path_rules / command_rules / file_rules | Layered: the global deny rules you wrote and Loop’s pinned default denies first, then the project rules, then the rest of the global rules (first-match-wins). Once the project config is trusted, the overridable default denies (credential dirs such as ~/.aws and ~/.kube, registry credential writes, rm -rf on an absolute path) move after the project rules, so a trusted project can allow them; until it is trusted, or after it changes, they stay first. A project can loosen a global approve or allow, never a deny you wrote. |
gates.agentgate.default_decision | Ignored — global wins unconditionally. |
gates.docker_proxy.enabled | Ignored, like gates.agentgate.enabled. |
gates.docker_proxy.http_rules / body_rules | Layered: global denies first, then the project rules, then the rest. None of the docker proxy defaults is overridable. |
gates.docker_proxy.default_decision | Ignored — global wins. |
gates.rate_limits / gates.audit | Ignored — global wins unconditionally. |
Project Config Merge Rules
The merge follows these principles:
- Replace: The project value completely replaces the global value (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, proxies.no_proxy, and mounts, where one at the same container path replaces the global one).
- 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 go after the global denies (a trusted project’s go before the overridable default denies) and before the other global rules,enabledis the global setting’s, anddefault_decision/rate_limits/auditare ignored. - Absent = inherit: If a field is not set in the project config, the global value is used unchanged.
Project Config Trust
The project config lives in the workspace, so an agent can edit it. The fields that reach past the agent’s container wait for you to trust them, like direnv allow:
mounts,inherit_mounts,extra_dirs,copy_files,envspermissions,gates,bash_shortcutsworkflows(their bash nodes run on the host underworkflow_bash_local, and a project workflow replaces the global one of the same name)container_image,container_image_autobuildand the files in.loop/container, the image’s build context (the gate and the docker proxy run from inside the agent’s image, and its container gets the host’s docker socket). The diff shows each file by its digest; Loop builds the image only from files that match the digests you trustedanthropic_base_url(the global credentials are sent there)browser.mode,browser.host_cdp_port,browser.extensions,browser.cookie_importmemory.pathsgithub.mount_gh_config,gitlab.glab_config_dir,gitlab.mount_glab_config(they hand the host’sgh/glabcredentials to the agent)ci(a failing check sends the agent a prompt, which starts a run unattended, and it picks how “merge when ready” merges)
Everything else (model, prompt shortcuts, MCP servers and so on) only shapes the agent’s own container and applies as written.
When those fields differ from the version you last trusted, Loop keeps using that version: none of the fields apply for a project you never trusted, and a project whose config sets none of them needs no trust, unless you trusted a version that did: removing them, or the whole file, keeps the trusted values until you trust again. The project’s row in the sidebar shows a trust pill, and its chats show a banner above the composer whose Review button opens Settings → Project. There, a Project config changed notice shows what changed as a diff from the last trusted version to the current one (from nothing, for a project you never trusted), with a Trust this config button. If the file changes again while you review it, trusting fails and the notice shows the new version.
Your own edits stay trusted. Saving the project config in Settings, adding a project shortcut, applying a learn proposal and loop onboard:local all keep a trusted project trusted. An edit you make to a project that’s waiting for review leaves it waiting, so it can’t approve what an agent changed elsewhere in the file. Edits in a text editor count like an agent’s: trust them in Settings afterwards.
Projects you set up before Loop asked for trust keep working: the upgrade trusts the config of every project with a channel as it is at that moment, once. A project that already has a trusted version keeps it. Workflows joined the trusted fields later, with a second upgrade step that works the same way: it adds each project’s workflows, as they are at that moment, to its trusted version, once, and leaves the project’s other trusted fields as you approved them. A project whose trusted version already has workflows keeps them. container_image, container_image_autobuild and the .loop/container files joined after that, with a third step that works the same way for them.
A worktree’s own .loop/config.json follows the same rule, except for the parent project dir Loop seeds into its extra_dirs, which always applies.
Symlinks in a project’s .loop dir can’t lead out of the project. Every read and write of a file there goes through the project dir, which refuses a link that leaves it at the moment of the call, so a link swapped in after a check is refused too:
- Shortcut, bash shortcut and component files that lead out are skipped as if missing.
- A project config write (Settings, a project shortcut or workflow, a learn proposal, a history restore) is refused with a
400when.loopor.loop/config.jsonis a symlink out of the project, so it can’t change another file, such as the global config, as the project’s. The temp file a learn proposal is written through can’t be redirected either. - A project config that leads out isn’t read. Runs in the project fail with a
loading project configerror, as they do when the file is malformed, and the Settings, history and trust views show nothing of the file it points to.
Relative links that stay inside the project work as before. An absolute link is refused, even one to a file in the project: point it at the file with a relative path instead. internal/osutil/within_spec_test.go checks this on random trees of links (out of the project, to a parent, out and back in, chains, loops, dangling) and names (., .., //, trailing slashes): nothing outside the project is read, created, changed or removed, and a relative link that stays inside still works.
Trusted versions are kept in loop/project-trust.json under your OS user config dir (~/Library/Application Support on macOS, ~/.config on Linux), next to the API token, where containers can’t reach them. The API routes are GET and POST /api/config/project/trust
.
Example: aws and kubectl
Loop’s default file rules deny the credential dirs ~/.aws and ~/.kube, even when you mount them. A project that runs aws or kubectl can allow them in its own .loop/config.json; the rules apply once you trust the config:
{
"mounts": ["~/.aws:~/.aws", "~/.kube:~/.kube"],
"gates": {
"agentgate": {
"file_rules": [
// Read the config and credentials, write the token caches.
{"paths": ["**/.aws/**", "**/.kube/**"], "operations": ["read", "write", "create"], "decision": "allow"},
// Clean up a cache temp file an interrupted write left behind.
{"paths": ["**/.aws/**/tmp*.tmp"], "operations": ["delete"], "decision": "allow"}
]
}
}
}The AWS CLI saves a token in ~/.aws/sso/cache or ~/.aws/cli/cache by writing a temp file (tmp<random>.tmp) and renaming it over the cache file, and kubectl writes ~/.kube/cache the same way. The gate checks a rename within one directory as a write of the temp file and a create of the cache file, so the first rule covers it. Deleting a file is a delete, which the second rule allows only for those temp files, so nothing else in ~/.aws or ~/.kube can be deleted. See Project config merge
for where project rules go.
Complete Example Config
{
// 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",
// Route Claude's API calls through a gateway or proxy (localhost = this machine)
//"anthropic_base_url": "https://llm-gateway.example.com",
// 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": 2048,
//"container_cpus": 1.0,
//"container_keep_alive_sec": 300,
//"live_sessions": true, // run chat turns in one long-lived claude process per channel; false = a container per turn
//"live_session_platforms": [], // platforms whose channels get live sessions ("local", "discord", "slack"); empty = all
//"live_session_idle_sec": 600, // stop an idle live session after this long (the next message starts it again); 0 = never
// 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,
// "persist_profile": true,
// "memory_mb": 2048, // memory cap per sidecar; 0 for no cap
// "cpus": 1.0, // CPU cap per sidecar, in cores; 0 for no cap
// "extensions": ["/Users/me/chrome-extensions/ublock"],
// "cookie_import": {
// "source": "chrome:Default", // browser profile to import cookies from
// "domains": ["github.com"], // cookie scopes to bring over
// "auto": false // re-import into every new sidecar
// }
//},
// GitHub integration (used by the Git panel's PR chip)
//"github": {
// "gh_user": "", // named gh CLI account; empty = gh's currently-active account
// "mount_gh_config": false // mount ~/.config/gh into agent containers
//},
// GitLab integration (MR lookups via glab)
//"gitlab": {
// "glab_config_dir": "", // GLAB_CONFIG_DIR for lookups; empty = glab's default
// "mount_glab_config": false // mount the glab config dir into agent containers
//},
// 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"],
// Proxy for containers and image builds (empty inherits the daemon's own
// environment, which is fixed when the daemon starts — these are re-read
// per run)
"proxies": {
"http_proxy": "",
"https_proxy": "",
// Extra entries that bypass the proxy, added to loop's own bypasses. Name
// every host reached by name: a hostname never matches a CIDR
"no_proxy": []
},
// 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"
}
],
// Chat component templates (the chat_component MCP tool); math, canvas and react are built in
"chat_components": [
{
"name": "reaction",
"description": "A chemical reaction, balanced step by step"
}
],
// 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" } ]
// //}
//}
}Project Example Config
{
// Claude model override for this project
//"claude_model": "claude-opus-5",
// 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",
// Gateway for this project's Claude API calls (needs trust)
//"anthropic_base_url": "https://llm-gateway.example.com",
// 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",
// "persist_profile": true,
// "memory_mb": 2048, // memory cap per sidecar; 0 for no cap
// "cpus": 1.0, // CPU cap per sidecar, in cores; 0 for no cap
// "extensions": ["/Users/me/chrome-extensions/ublock"],
// "cookie_import": {
// "source": "chrome:Default", // browser profile to import cookies from
// "domains": ["github.com"], // cookie scopes to bring over
// "auto": false // re-import into every new sidecar
// }
//},
// GitHub override (per-project gh CLI account; mount_gh_config needs trust)
//"github": {
// "gh_user": "",
// "mount_gh_config": false
//},
// GitLab override (needs trust)
//"gitlab": {
// "glab_config_dir": "",
// "mount_glab_config": false
//},
// 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 (added to the global mounts; one at the same container
// path replaces the global one; relative paths resolved to project dir)
//"mounts": [
// "~/dev/shared-lib:~/dev/shared-lib:ro",
// "./fixtures:/fixtures:ro"
//],
// Make the mounts above replace the global list instead
//"inherit_mounts": false,
// Proxy for this project (replaces the global one when set)
//"proxies": {
// "http_proxy": "http://127.0.0.1:3128",
// "https_proxy": "http://127.0.0.1:3128",
// // Extra no-proxy entries for this project (appended to the global list).
// // Name the compose services the agent talks to: a sibling reached as
// // http://my-service:8080 is matched by name, which no IP range covers.
// "no_proxy": ["my-service", "my-cache"]
//},
// 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"
// }
//],
// Chat component templates (merged by name; project overrides global and built-in templates with same name)
// Files load from .loop/components/<path>/, falling back to ~/.loop/components/<path>/
//"chat_components": [
// {
// "name": "reaction",
// "description": "A chemical reaction, balanced step by step"
// }
//],
// 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 gate rules for this project, applied once you trust the config.
// Rules may use any decision (allow/deny/approve) and go after the global
// denies, before the other global rules (first-match-wins). "enabled" and
// "default_decision" are ignored: the global config decides.
//"gates": {
// //"agentgate": {
// // //"command_rules": [ { "commands": ["npm"], "args_patterns": ["^publish"], "decision": "deny" } ],
// // //"file_rules": [ { "paths": ["./secret-vault/**"], "operations": ["read"], "decision": "deny" } ]
// // // Let aws and kubectl use the mounted ~/.aws and ~/.kube, and clean up
// // // the cache temp files the AWS CLI renames into place:
// // //"file_rules": [
// // // { "paths": ["**/.aws/**", "**/.kube/**"], "operations": ["read", "write", "create"], "decision": "allow" },
// // // { "paths": ["**/.aws/**/tmp*.tmp"], "operations": ["delete"], "decision": "allow" }
// // //]
// //},
//
// //"docker_proxy": {
// // //"http_rules": [ /* project-specific Docker API restrictions */ ]
// //}
//}
}