Skip to content

Configuration

core-agent walks up from the working directory looking for a folder named .agents/, analogous to how git looks for .git. The first match wins. Everything core-agent reads or writes for a project lives there:

.agents/
├── config.json # this file — provider, model, permissions, scope, telemetry, etc.
├── mcp.json # MCP server declarations (see MCP page)
├── skills/ # SKILL.md bundles (see Skills page)
└── sessions/ # one-shot transcripts; auto-written, safe to .gitignore

You don’t have to create .agents/ — without it, core-agent runs with built-in defaults and skips the project-specific bits (no transcripts, no MCP, no skills). It’s required only when you want to customize.

Beyond the project .agents/, core-agent reads a few user-scope paths for assets that follow you across projects:

PathContentsNotes
~/.agents/AGENTS.md, AGENTS.d/*.md, skills/, mcp.jsonPortable user assets, layered under project scope but above the legacy ~/.core-agent/ fallback. Use this as the primary user root.
~/.core-agent/AGENTS.md, AGENTS.d/*.md, skills/, pricing.jsonHistorical user root plus runtime cache (pricing.json — auto-fetched pricing data, /pricing set writes). AGENTS.md + skills/ remain read here as a lower-precedence fallback.

Per-loader precedence (higher-scope entries win on collision):

  • Skills: <project>/.agents/skills/ > ~/.agents/skills/ > ~/.core-agent/skills/ — merged via overlay; project wins on skill-name collision.
  • AGENTS.md: user (~/.core-agent/) → user-home (~/.agents/) → project — concatenated in order; canonical-path visited-set dedupes cross-scope duplicates.
  • MCP servers: <project>/.agents/mcp.json > ~/.agents/mcp.json — merged by server-name key; project wins on collision. Non-server fields (agentic_wrap*) take the first explicitly-set value.
  • Config: <project>/.agents/config.json only — no user-scope layering today. If you want personal defaults, use the CLI -c ~/.agents/config.json to point at a HOME file explicitly.

AGENTS.md is the single-file baseline (with CLAUDE.md / GEMINI.md as first-match-wins fallbacks). For larger instruction sets, two composition primitives let you split the prompt across multiple files without changing your model or wrapping code.

Both primitives work at three scopes, loaded and concatenated in this order:

ScopeSearched firstFallback location
User (~/.core-agent/)~/.core-agent/.agents/AGENTS.md and ~/.core-agent/.agents/AGENTS.d/*.md~/.core-agent/AGENTS.md and ~/.core-agent/AGENTS.d/*.md
User-home (~/.agents/)~/.agents/AGENTS.md and ~/.agents/AGENTS.d/*.md— (the root IS already .agents/; no nested fallback)
Project<project-root>/.agents/AGENTS.md and <project-root>/.agents/AGENTS.d/*.md<project-root>/AGENTS.md and <project-root>/AGENTS.d/*.md

Each scope’s primary file + AGENTS.d/*.md are concatenated into the prompt in the order above (user → user-home → project). The per-load canonical-path dedup ensures any single file reached from multiple paths (via @include, via both AGENTS.d directories, via cross-scope symlinks) loads exactly once.

Instruction files are prose, not templates. Whatever you write reaches the model byte for byte — braces included, so {placeholder}, ${SHELL_VAR}, a Go generic, or a {a,b} brace expansion in a command example are all just text. The one substitution that happens in loaded content is ${env:NAME}, and only when the bundle ships an env.yaml manifest. Until #1139 this was not true: the prompt was handed to the model runtime as a template, and a bare {word} named a variable nothing had set, which ended every turn before a token was sent.

Why two user-level roots? ~/.agents/ is the portable cross-tool convention — the same layout you’d use inside a project’s .agents/ but at $HOME. ~/.core-agent/ is the historical core-agent-specific root and remains supported. Drop your rules in whichever fits; both load additively.

Within the project scope, both locations (.agents/ subdir and root) load additively — .agents/AGENTS.md content appears first, followed by <root>/AGENTS.md. Operators following the “everything agent-related lives under .agents/” convention drop their files in the subdir; operators following the broader-ecosystem <project-root>/AGENTS.md convention (Cursor, Antigravity, Hermes) keep them at root. Both work. Mixing is supported — root AGENTS.md as the cross-tool canonical document plus .agents/AGENTS.md for core-agent-specific additions is a legitimate layout.

Files within each scope load in this order:

  1. User scope (~/.core-agent/): primary AGENTS.md from either location, then AGENTS.d/*.md lexically (from both directories, merged).
  2. User-home scope (~/.agents/): primary AGENTS.md, then AGENTS.d/*.md lexically.
  3. Project scope: primary AGENTS.md (or CLAUDE.md / GEMINI.md) from either location, then AGENTS.d/*.md lexically (from both directories, merged).

A line whose entire content is @include <path> (with optional leading whitespace) is replaced in-place by the referenced file’s content. Useful for layering shared principles + per-project overrides:

# Agent instructions
You are a GKE on-call orchestrator for the payments team.
@include base/principles.md
@include workflows/triage.md
## Project-specific overrides
Default cluster: prod-us-central1.

Rules:

  • Relative to the including file’s directory. So AGENTS.md @include workflows/triage.md resolves to <dir-of-AGENTS.md>/workflows/triage.md.
  • ../ is permitted up to the scope root (project root or user-agent dir). Escaping the scope root is an error.
  • Absolute paths and URLs are rejected — local files only.
  • Cycles handled by dedup — A → B → A loads A and B once each, no error.
  • Max nesting depth: 8. Beyond that errors fast (real trees rarely exceed 2–3).
  • Missing target = load error. Typos surface immediately rather than silently shrinking the system prompt.
  • Inside fenced code blocks (``` or ~~~) the directive is left literal so docs-about-includes don’t expand.
  • Embedded in prose (e.g. “see @include foo for details”) is NOT processed — directive lines only.

Drop a directory next to your primary file:

.agents/
├── AGENTS.md
└── AGENTS.d/
├── 10-principles.md
├── 20-tools.md
└── 30-workflows.md

Every top-level .md file is loaded in lexical filename order, appended after the scope’s primary file. Conventions:

  • .md only. Other extensions (.txt, README) are ignored.
  • Top-level only. Subdirectories are not recursed.
  • Hidden files skipped (.staging.md, .draft.md) — useful for staging work-in-progress entries.
  • Absent directory is fine — just no fan-in for that scope.

A leading YAML frontmatter block (between --- lines at the very start of a file) is stripped before the body is added to the system prompt. The loader does not parse the metadata in v1 — this just keeps editor metadata out of the model’s view.

---
title: Triage workflow
tags: [oncall, gke]
---
# When an operator pages...

A --- later in the file (used as a markdown horizontal rule) is not treated as frontmatter.

Each loaded file is capped at 32 KiB. Files larger than the cap are truncated and the assembled prompt gets a [...truncated by core-agent at 32768 bytes...] marker so both the model and the operator know.

FromRecipe
Single AGENTS.mdNo change. v2 loads existing files identically.
Cursor (.cursor/rules/*.mdc)Rename the rules/ directory to AGENTS.d/ and rename .mdc → .md. Frontmatter is stripped automatically.
Antigravity (AGENTS.md with @include)Drop in as-is — the directive syntax is identical.
Hermes (root-level AGENTS.md + SOUL.md)Concatenate or split. To keep both: write a project-root AGENTS.md that just contains @include SOUL.md (or move SOUL.md to AGENTS.d/20-soul.md). Note: Hermes’s MEMORY.md / USER.md are runtime memory concerns, not static instructions — they belong in core-agent’s shared-memory layer, not the loader.

The /memory slash command (and Loaded.Sources from the library API) lists every file that contributed to the assembled prompt — primary, included, and AGENTS.d/-scanned — with their canonical paths so you can trace where any line in the prompt came from.


Top-level shape, with all fields optional except version and model.name:

{
"version": 1,
"model": { ... },
"permissions": { ... },
"path_scope": { ... },
"agent": { ... },
"tool_output": { ... },
"otel": { ... },
"url_scope": { ... },
"content_roots": [ ... ],
"attach": { ... }
}

version must be 1. Other versions are rejected with a clear upgrade message — the schema is bumped only on breaking changes.

A minimal viable config:

{
"version": 1,
"model": {
"provider": "anthropic",
"name": "claude-opus-5"
}
}

Interactive flows (/allow, /deny, /model, /theme, /mouse, /permissions layout, “always allow this path”) edit config.json in place. The writer is deliberately conservative:

  • Partial stays partial. Only the sections you actually set are written — substrate defaults are never materialized into the file. This keeps a future bump to a default (e.g. the default model) reaching you instead of being pinned to whatever was current when the file was first written.
  • Unknown keys are preserved. A section written by a newer build is kept verbatim on round-trip, so an older build editing the file no longer drops fields it doesn’t recognize. A misspelled key (e.g. permisions) is preserved but logs a warning at load, since it otherwise has no effect.
  • Permissions are protected. A new file is created mode 0600 (the schema can hold api_key values); an existing file keeps whatever mode it already has — the writer never widens it.

Selects the LLM backend.

FieldTypeDefaultNotes
providerstring"" (auto-detect)One of gemini, vertex, anthropic, anthropic-vertex. Empty = auto-detect from env.
namestringgemini-3.7-flashModel ID. Required. For Gemini, version 3.0 or later is required when using the default tool suite — see Providers → Gemini 3.0+ required. The default is a current-generation, generally-available flash model that combines server-side search built-ins with function tools out of the box. Override with a pro-class model, or with the gemini-3.1-pro-preview-customtools variant (fine-tuned to prefer developer-defined tools over raw bash), when you want that behavior.
api_keystring""Inline key for provider: gemini. Usually unset; read from GOOGLE_API_KEY / GEMINI_API_KEY at runtime.
vertexobjectnullGCP project + region. Required when provider: vertex.
vertex.projectstring—GCP project ID.
vertex.locationstring—GCP region (e.g. us-central1).
anthropicobjectnullClaude-specific settings.
anthropic.api_keystring""Inline Anthropic key. Usually read from ANTHROPIC_API_KEY.
anthropic.vertexobjectnullWhen provider: anthropic-vertex, holds project + region.
anthropic.vertex.projectstring—GCP project ID for Vertex Anthropic. Falls back to ANTHROPIC_VERTEX_PROJECT_ID then GOOGLE_CLOUD_PROJECT.
anthropic.vertex.locationstring—Region (e.g. us-east5). Falls back to CLOUD_ML_REGION then GOOGLE_CLOUD_LOCATION.
anthropic.prompt_cacheobjectnullAnthropic prompt caching. Absent = enabled with defaults. Applies to both anthropic and anthropic-vertex — the cache_control breakpoints ride the ordinary request, so there is no cache resource and no Vertex-only restriction. See Providers → Prompt caching.
anthropic.prompt_cache.enabledbooltruefalse disables prompt caching for this project. The --no-prompt-cache CLI flag overrides this in the off direction; it cannot turn caching back on. A subagent with its own model block inherits this value unless it sets its own.
anthropic.prompt_cache.ttlstring"5m"Lifetime of the cache entries the breakpoints write: "5m" or "1h". Any other value is a load-time error. A 1-hour write bills at 2× the input rate where a 5-minute one bills 1.25×, so "1h" pays off only when turns are more than five minutes apart and the prefix recurs within the hour; both are priced separately by pkg/pricing. --prompt-cache-ttl overrides it for one run. See Providers → Entry lifetime.
builtin_toolsobjectnullGates the provider’s server-side built-in tools — the ones the model runs inside the provider’s own infrastructure, which never surface as a tool call. A different axis from tools.disable and --no-builtin-tools, which govern core-agent’s own function tools. Absent = every tool keeps its provider default. The startup summary’s model: line names the effective set. See Providers → Configuring built-ins.
builtin_tools.web_searchboolprovider defaultServer-side web search grounding. Provider defaults are not symmetric — Gemini’s google_search is on, Anthropic’s web_search is off — so a deployment that switches provider switches its agent’s internet reachability unless this states the posture. Tri-state: omit to keep the default; only an explicit true/false moves it. A subagent with its own model block inherits this value, per field, unless it sets its own.
builtin_tools.url_contextboolprovider default (Gemini: true)Lets the model fetch and ground on URLs it decides to visit. Gemini only; ignored on Anthropic, which surfaces no equivalent.
builtin_tools.code_executionboolprovider default (Gemini: false)Sandboxed Python on the provider’s servers. Gemini only; ignored on Anthropic.
pricingmap{}Per-model rate overrides keyed by model name (case-insensitive). Survives /model switches mid-session — every model the operator routes to can carry its own rates.
pricing.<model>.input_per_mtokfloat—USD per 1M input tokens for <model>.
pricing.<model>.cached_input_per_mtokfloat—USD per 1M cache-read tokens (the discounted rate for reusing a cached prefix). Unset bills cache reads at input_per_mtok.
pricing.<model>.cache_creation_input_per_mtokfloat—USD per 1M cache-write tokens (the premium for establishing a cache entry — 1.25× input on Anthropic’s 5-minute TTL). Unset bills writes at input_per_mtok.
pricing.<model>.output_per_mtokfloat—USD per 1M output tokens for <model>.

Pricing resolves through a layered chain: this model.pricing map → .agents/pricing.json (project-local) → ~/.core-agent/pricing.json (user-global; auto-fetched + manual sections) → compiled-in fallback → longest-prefix match → ”$—” (rate unknown).

Example:

{
"model": {
"name": "gemini-3.1-pro-preview",
"pricing": {
"gemini-3.1-pro-preview": {"input_per_mtok": 1.25, "output_per_mtok": 5.00},
"claude-opus-5": {"input_per_mtok": 15.0, "cached_input_per_mtok": 1.50, "cache_creation_input_per_mtok": 18.75, "output_per_mtok": 75.0},
"internal-fine-tuned-v3": {"input_per_mtok": 0.50, "output_per_mtok": 2.00}
}
}
}

See Providers for full details on each backend.


Configures the permission gate that consults every tool call. See Permissions for the full pattern grammar.

FieldTypeDefaultNotes
modestringaskOne of ask, allow, yolo, plan, acceptEdits. acceptEdits auto-allows all file writes including out-of-scope paths — sandbox-only posture; see Permissions → Modes.
allowstring[][]Allowlist patterns. Format: <tool>:<glob> or <glob>.
denystring[][]Denylist patterns. Always wins over allow.
use_builtin_allowbooltrueInclude the built-in read-only bundle in the effective allowlist (reads, greps, list_dir, git status / git diff, etc.). Prefix-matched bash entries only auto-allow single literal simple commands — chained/piped/redirected commands and dangerous find predicates (-exec, -delete, …) still prompt; see Permissions → Safe-command guard. Turn off if you want to allowlist every tool from scratch.
builtin_allow_extrasstring[][]Names of additional built-in bundles to fold into the effective allowlist (e.g. ["testing", "linting"]). See permissions.Bundles in the Go source for the current catalog; also configurable interactively via the /allow-bundle slash.
plan_modestringoffOne of off, advisory, required. Whether record_plan is registered, and whether mutating tools are gated on it. See Plan mode. CLI: --plan-mode.
require_plan_artifactboolfalseDeprecated (v2.9) — the two-state spelling of plan_mode; true == plan_mode: "required". Cannot express advisory. Removed in the next major.
approval_timeoutstring"" (wait forever)Go duration bounding how long one gated call waits for an answer before failing with ErrPromptExpired. See Approval timeout.
approval_notifystring"" (off)Name of an alert target to notify when a gated prompt opens and nobody is attached. See Out-of-band approval.
approval_notify_afterstring"" (off)Go duration. A prompt that reached an attached client and is still unanswered after this long is sent to approval_notify too. Requires approval_notify, and must be shorter than approval_timeout when that is set. See Attached is not watching.

Example:

{
"permissions": {
"mode": "ask",
"allow": ["bash:git status", "bash:git log*", "read_file:internal/**"],
"deny": ["bash:sudo *"]
}
}

In ask mode the bundled CLI (core-agent) prompts on stderr whenever a tool call needs approval. The prompt looks like:

core-agent (permissions): bash wants to run:
rm -rf /tmp/foo
[y]es once · [s]ession · session-[t]ool · [a]lways · [N]o (default):

Decision keys (case-insensitive, single character + enter):

KeyEffect
yAllow once. Next identical call asks again.
sAllow this exact request for the rest of the session.
tAllow every call to this tool for the rest of the session.
aAllow always. Persists an entry to .agents/config.json’s permissions.allow.
n or bare enterDeny.

This is the stdin prompter. The TUI (--tui, and core-agent-tui in attach mode) asks with the same decisions plus r, deny with a one-line reason of at most 500 bytes that the model reads in the refused call’s result. core-agent-tui offers r only against a daemon on attach protocol 1.15.0 or later; see Attach TUI → Permission prompts.

The prompter is auto-wired when stdin is a TTY. Non-TTY callers (piped stdin, CI, nohup) get ErrNoPrompter-wrapped errors that point at the bypass options below — they don’t hang waiting for a non-existent user.

--yolo forces the gate into yolo mode regardless of config.permissions.mode. Equivalent to setting permissions.mode: "yolo" in config; takes precedence at the call site so you don’t have to edit config to unblock a one-off scripted run. Library callers achieve the same with permissions.Options{Mode: permissions.ModeYolo}.

Approval timeout (v3.0+) — approval_timeout

Section titled “Approval timeout (v3.0+) — approval_timeout”

An unanswered prompt is not a slow prompt, it is a stopped agent.

The gate serializes prompts, and an ordinary turn carries no deadline of its own. So a single gated call with nobody attached to /perms/stream blocks that turn indefinitely — and the session goes on reporting working the whole time, which from outside the process is indistinguishable from work in progress. On a desktop that is fine: somebody is looking at the prompt. On an unattended daemon it is the failure mode that looks most like health.

permissions.approval_timeout bounds it:

{
"permissions": {
"mode": "ask",
"approval_timeout": "10m"
}
}

Empty or absent means wait forever, which stays the default — timing out an operator who is reading a diff before approving it would be a regression of the case the gate exists for. Pick the value from how long your approval channel takes a human to reach, not from how long the tool call takes to run: the clock is measuring the operator, not the cluster.

When it fires, the call fails with permissions.ErrPromptExpired and the action is not taken. That is a distinct sentinel from a cancelled turn on purpose — “somebody pressed stop” and “nobody answered” are different events, and only the second one means the approval channel is not being watched. A turn the operator cancelled themselves is still reported as a cancellation even if the timeout was about to fire.

Both refusals — a denial and an expiry — also tell the model that the answer will not change: a denial is final for the call it answered, and an expiry means nobody is attached to answer a retry either, so the agent is told to stop rather than to try again. That sentence exists because of what happened without it on the cluster (#1068): a refusal that reports an event and nothing else reads to a model with an instruction as an obstacle to route around, and one out-of-band denial turned into five identical calls, a watchdog halt, and a daemon that refused every subsequent turn until an operator reset the guardrail.

Guidance turned out not to be enough. Re-running that rig against the first image carrying those sentences (#1074) showed them delivered, read, and quoted back by the watchdog as its Last error — with the loop unchanged: eight prompts on a run whose floor is three. So the gate now stops asking. After a denial or an expiry, an identical request — same tool, same detail — is refused for the rest of the turn without opening a prompt and without notifying anybody, and the refusal says it is a repeat rather than reading as a fresh answer. Three scoping rules follow from what that could get wrong:

  • Only a refusal arms it. An approved call that comes round again gets an ordinary prompt; saying yes once is not evidence about the next one.
  • It is keyed on the request, not the tool. An agent whose kubectl apply was refused can fix its manifest and ask about the new one, because that is a different request.
  • It clears at the next turn boundary — the same boundary the watchdog’s turn-scoped signals clear on, and for the same reason it sits after the pre-turn refusals: a turn that never ran is not a boundary, so an auto-continue re-drive cannot launder a denial and re-open the prompt. By the next real turn the model has the refusal in its history and the operator’s circumstances may have changed, which is why this is not session-scoped: one misclick should not silently disable a tool for the afternoon.

Nor was that enough on its own, and the third run said why (#1081): five fewer prompts, zero fewer calls, and the same watchdog critical byte for byte. The turn was being cut — within a millisecond of the critical, by the watchdog’s in-turn enforcement — but the thing doing the cutting was session-scoped, so nine milliseconds later the daemon refused the next turn and auto-continue stood down until a human reset it. One “no” still ended the daemon’s working day. So the gate ends the turn itself: after three calls refused in a turn without asking anybody, the turn stops, and only the turn. Nothing trips, nothing needs resetting, and the next turn runs. It is not configurable, for the reason the three bullets above are not: a deployment in which the agent keeps re-issuing a call the operator refused is not a preference anyone would express. See DecisionDeny’s scope for the threshold’s reasoning and what the cut records.

The fourth run is the one that finished the sequence, and it moved the fault out of the gate entirely (#1090). Everything above worked: one denial, no repeat prompts, the turn cut, nothing tripped. Then the re-driven turn called mark_task_done four times to write the denial down, three of those came back inert, and the no-op-streak Critical halted the session — for recording a refusal, on a turn where the model had already stopped doing the refused thing. So that signal is now turn-scoped: it ends its turn and the next one runs, and it takes three consecutive turns ending that way to halt the session. Reaching that number is the shape nothing smaller answers — a model restarting the loop on every fresh turn — and one clean turn or an operator reset clears the count.

The privilege-bearing control-plane write path follows the same rule, and needs it most — a re-issued write to .agents/config.json pages the operator repeatedly about the file that decides what the agent is allowed to do.

One consequence shows up at the API. An operator answering POST /perms/respond after the deadline gets 410 Gone, not 404, with a body saying the action was not taken. Out-of-band approval means slow humans — somebody reads a notification, thinks about it, and approves at minute eleven of a ten-minute window — and 404 not found would leave them unable to tell whether the write had gone ahead on somebody else’s answer. The daemon remembers a bounded number of recently-departed request ids to be able to say this.

A prompt the clock took is not the only kind that goes away. Since protocol 1.14.0 the same 410 covers a prompt whose turn ended while it was still open — an operator’s stop, a guardrail cutting the turn, a daemon going down — with a body that says so rather than blaming the deadline, because the two prescribe different fixes and only one of them is “answer faster” (#1088). 404 narrows to what it can still honestly claim: an id already answered, or one this daemon never issued. See Answering a prompt that is gone.

Out-of-band approval (v3.0+) — approval_notify

Section titled “Out-of-band approval (v3.0+) — approval_notify”

approval_timeout stops an unwatched prompt from hanging forever. It does not tell anybody the prompt happened — so on its own it converts a hung agent into an agent that quietly gives up. Better, but still not something you can leave running.

permissions.approval_notify closes the other half. Name a target from your alerts registry, and when a gated call opens a prompt that reaches nobody, one notification goes out carrying what is being approved, which session asked, the request id, and when it expires:

{
"permissions": {
"mode": "ask",
"approval_timeout": "10m",
"approval_notify": "oncall"
},
"alerts": {
"targets": [
{ "name": "oncall", "template": "slack", "url_env": "SLACK_ONCALL_WEBHOOK" }
]
}
}

The recipient answers with the request id the notification carried:

Terminal window
curl -X POST "$DAEMON/sessions/core-agent/$SID/perms/respond" \
-H 'Content-Type: application/json' \
-d '{"id":"<request_id>","decision":"allow-once"}'

Four details worth knowing:

  • A target name, never a URL. The alerts registry already owns destinations, auth and templates, and reusing it keeps this path SSRF-safe by construction — nothing here can be pointed at an address you did not pre-register.
  • It fires on silence, not on every prompt. If a /perms/stream subscriber received the frame, no notification goes out — unless approval_notify_after is set and the prompt then sits unanswered that long (see below). Escalating every prompt of an interactive session is noise that trains the recipient to mute the channel. A subscriber that is attached but has stopped draining counts as silence, because from the operator’s side it is.
  • An unusable target fails at startup. An unknown name is a config error; a name whose webhook env is unset refuses the boot. An operator who set this field has told you they are not reading the console, so a warning printed there would be delivered to the one place they said they would not look.
  • It has its own rate-limit budget, separate from the alert tool’s. Otherwise an agent firing alerts in a loop could exhaust the budget for the channel that governs that same agent.

If delivery fails the prompt is unaffected — it stays answerable, and the failure is logged as approval notification failed. The gate never waits on a webhook.

Attached is not watching (v2.10+) — approval_notify_after

Section titled “Attached is not watching (v2.10+) — approval_notify_after”

A client that stays attached while its operator is away receives every prompt, so none of them count as unwatched and the approval channel hears about none of them. Set approval_notify_after to cover that case too:

{
"permissions": {
"mode": "ask",
"approval_timeout": "30m",
"approval_notify": "oncall",
"approval_notify_after": "10m"
}
}

A prompt that reached an attached client and is still unanswered after ten minutes is sent to oncall. The notification says an attached client has not answered, and carries unanswered_for in its details. A prompt that reached nobody is still sent at once, and each prompt produces at most one notification either way. The field is rejected at load if approval_notify is unset, or if it is not shorter than approval_timeout, because either way the notification could never be sent (#1167).

record_plan does two separable things: it persists a plan artifact to .agents/plans/plan-<seq>.md (pure audit value), and it clears the plan-first gate that blocks mutating tools. Until v2.9 both were behind one bool, so you could not get the audit trail without the two-turn ceremony — an autonomous triage or alert-response agent had to turn the whole feature off and lose the artifact with it. permissions.plan_mode separates them:

plan_moderecord_plan registered?artifact written?mutating tools gated?
"off" (default)nonono
"advisory"yesyesno
"required"yesyesyes
{
"version": 1,
"permissions": {
"mode": "yolo",
"plan_mode": "advisory"
}
}

Advisory mode is the plan is generated and auto-approved: the model records what it intends to do, the operator gets the artifact, and nothing stalls waiting for approval. Nothing in the runtime makes the model call record_plan in advisory mode — prompting it is the recipe’s job (AGENTS.md or a skill). A workable shape:

## Response protocol
Every inject-triggered turn MUST start with a `record_plan` call containing:
1. **Diagnosis** — what's failing, one sentence.
2. **Root cause hypothesis** — one sentence.
3. **Planned actions** — the specific tool calls you intend to make, in order.
4. **Verification** — how you'll confirm it worked.
Then carry the plan out in the same turn. Nothing is blocking on the plan; do not stop and wait for approval.

The record_plan tool description is mode-aware, so in advisory mode the model is told the gate is off rather than being warned about a denial that will never happen.

permissions.require_plan_artifact is deprecated and removed in the next major. It still works: true means plan_mode: "required", and absent/false means “no opinion” (fall through to plan_mode, then the task class, then off). plan_mode wins when both are set — except plan_mode: "off" alongside require_plan_artifact: true, which is a half-done migration and fails at config load rather than silently picking a winner.

Library callers: read the resolved value through cfg.Permissions.ResolvedPlanMode(), or the two predicates PlanToolRegistered() / PlanGateArmed(). Reading either raw field is how the two spellings drift.

Plan-first gating (v2.3+) — plan_mode: "required"

Section titled “Plan-first gating (v2.3+) — plan_mode: "required"”

Setting permissions.plan_mode: "required" turns on substrate-enforced plan-before-action. The gate denies mutating tool calls (write_file/edit_file/delete_file/bash, fetch_url, spawn_agent/spawn_remote_agent, and all MCP tools) until the model has called the record_plan built-in tool. Read tools (read_file/read_many_files/view_file_outline/stat/list_dir/glob/grep/json_query/todo) and record_plan itself remain allowed so research happens normally and the model has an escape valve. fetch_url is deliberately plan-gated (v2.8+): it is network egress with a model-controlled URL — an exfiltration channel — so it only unlocks once a plan is recorded, like every other action tool.

Spawning is gated as of v2.9. Before that the plan gate governed only what a subagent went on to do, never the act of creating one — so a parent with no plan could fan out a fleet that did the acting. Note the asymmetry the fix keeps: stop_agent is not gated in any mode, because a denial there leaves running exactly what the model was trying to halt. An operator who wants delegation without cancellation withholds the tool instead (see subagents[].tools). A declarative subagent is also callable directly as a parent tool, not only by reference from spawn_agent; both routes are matched under the same spawn_agent policy bucket, so one rule closes both.

Once record_plan(plan: <markdown>) is called, the plan is written to .agents/plans/plan-<seq>.md and the gate’s planRecorded flag flips. From that point on, the configured mode resumes its usual semantics — see the composition table below.

{
"version": 1,
"permissions": {
"mode": "ask",
"plan_mode": "required",
"allow": ["read_file", "read_many_files", "grep", "glob", "list_dir", "stat", "json_query", "todo"]
}
}

Plan-first composes with every existing mode. Pick the post-plan friction level you want:

CompositionBehavior after record_plan
ask + plan_mode: "required"writes prompt per call (“approve each step”)
acceptEdits + plan_mode: "required"writes auto-allow, bash still prompts
yolo + plan_mode: "required"everything auto-allows (“just tell me the plan”)

The third row is the “we just want to know the plan, then go” case — no new mode value needed; yolo’s “no prompts” promise still holds after the plan; the only deny is the one-time gate before the plan exists.

Plans persist to <project-root>/.agents/plans/plan-<seq>.md with monotonically increasing sequence numbers. When the operator runs /replan, the active plan is renamed to plan-<seq>-revoked.md (audit trail preserved), the gate flag clears, and the model is forced back through record_plan before any further mutating tool will succeed. Sequence numbers continue across revocations so revisions are always identifiable.

A sequence number is allocated per plan, not per call. Within one turn a second record_plan from the same author revises the artifact it already wrote instead of filing a sibling, and re-sending an unchanged plan writes nothing at all — see record_plan repeats don’t mint plan files.

PathContent
.agents/plans/plan-1.mdfirst plan
.agents/plans/plan-2-revoked.mdoperator /replan’d this one
.agents/plans/plan-3.mdcurrently active plan

Since v2.9 each artifact opens with a small YAML block naming its author — plan:, plus agent: and session: when the handler ran inside an invocation. One .agents/plans/ directory is shared by a parent and its declarative subagents, and by every tenant in multi-session mode, so without attribution the directory is a pile of anonymous markdown. Keys are omitted rather than emitted empty.

---
plan: 2
agent: "cluster"
session: "s-8f21"
---

Add .agents/plans/ to .gitignore if you don’t want plans checked in. Or do check them in — they make excellent PR descriptions.

Available in both the in-process TUI (core-agent) and the remote TUI (core-agent-tui). Optional reason argument: /replan reconsider scope. Effects: archive this agent and session’s active plan → clear gate flag → next mutating call gates again. Operator typically types a follow-up prompt explaining the rejection so the next record_plan reflects the new direction.

The scoping matters once a subagent is in the picture: before v2.9 the command took whichever artifact had the highest sequence, so a subagent’s plan-2 was revoked instead of the operator’s plan-1. Now a newer plan belonging to someone else is named in the response and left alone, and a directory where no plan carries attribution (written before v2.9) still falls back to newest-wins. Either way the gate flag clears.

gate, err := permissions.FromConfig(cfg, projectRoot, userRoot, prompter)
// or directly:
gate := permissions.New(permissions.Options{
Mode: permissions.ModeAsk,
RequirePlanArtifact: true,
})
// ... after record_plan tool fires its handler ...
gate.IsPlanRecorded() // → true
gate.ClearPlanRecorded() // /replan-like reset; pair with tools.RevokeLatestPlan to also archive
// Owner-scoped revocation (v2.9+) — what the CLI's /replan uses. The zero
// PlanOwner is newest-wins, so RevokeLatestPlan is just RevokePlanBy(…, {}).
tools.RevokePlanBy(gate, agentsDir, tools.PlanOwner{Agent: "core_agent", Session: sid})
tools.ActivePlans(agentsDir) // []PlanInfo{Path, Sequence, Agent, Session}, newest first

If your host wires tools by hand rather than through tools.Build, call gate.RegisterPlanGatedTools(names...) with what you registered. That set is what record_plan’s result names back to the model; a gate nobody told stays in the “unknown” state and the message declines to enumerate rather than claiming nothing is gated. The gate drops plan-exempt names itself, so hand over the whole catalog.

tools.Build registers the record_plan tool only when permissions.plan_mode is "advisory" or "required" AND agentsDir != "" (an inert record_plan with nowhere to write would be confusing). Library callers wanting plan artifacts should pass an agentsDir to tools.Build.

--plan-mode=off|advisory|required is the command-line mirror of permissions.plan_mode, and it is how you override a task class that turns the gate on: --task=debug --plan-mode=advisory keeps the audit artifact and drops the ceremony.

Precedence, strongest first: --plan-mode > --plan-first (either value) > permissions.plan_mode > require_plan_artifact: true > the task-class default > off. Each deprecated spelling sits directly under its replacement, so an old config keeps behaving the way it did and either flag still beats config. The task class can only reach "required" — an operator who wrote a mode in config is never overruled by a class default.

--plan-first is deprecated: it can only say required (true) or off (false). --plan-mode wins when both are passed, so a script can migrate without a flag-day.

The binary refuses to hand you a gate you can’t clear. If record_plan won’t register — no .agents/ directory, --no-builtin-tools, or the tool sitting in tools.disable / --disable-tools — a task class’s plan-first default is suppressed and startup says which of those it was. An explicit --plan-mode=required / --plan-first / config true is still honored there, because you asked for it out loud, but startup warns that every mutating call will be denied with no way to clear the flag: /replan revokes a plan, it can’t grant one. Advisory mode can’t deadlock, but it can be inert under the same conditions — no record_plan, no artifact — so startup says that too.

/replan stays available in advisory mode — it archives the latest artifact — but its response says so rather than promising a denial: advisory arms no gate, so nothing is blocked while the agent redrafts.

Full recipe: examples/plan-first/ ships four config.json variants (one per row of the composition table, plus an advisory one) and an AGENTS.md priming the model on the workflow. Design: docs/plan-first-design.md.

When background subagents are enabled (default; --no-background-agents disables them) and one of them triggers a permission prompt in ask mode, the heading is prefixed with [<subagent-name>] so you know which agent is asking. Concurrent prompts from different subagents are serialized through a mutex — they queue rather than race for stdin.

The subagent inherits the parent’s gate wholesale: the same allow/deny lists, the same mode, the same session-level approvals. If you approve session-tool: bash while a subagent is asking, every subagent gets the grant for the rest of the session (sibling included). Bounded-subset grants where the parent’s model arbitrates out-of-subset requests is deferred to v1.3+.

Teaching the model to use the spawn tools. Just registering the tools isn’t always enough — most models default to doing things synchronously. Drop a short paragraph into your project’s AGENTS.md (or pass via agent.WithExtraInstruction, which composes with the layered baseline — v2.8) describing when background subagents are appropriate (monitoring, fan-out, long bounded delegations). See Library API → Background subagents → Prompting patterns for a ready-to-paste system instruction.

A top-level subagents array declares a fixed roster of named delegates the parent can call by name — the config-driven counterpart to runtime spawn_agent. Each entry becomes a tool on the parent (named after the subagent), invoked like any other tool; the subagent runs headless in its own session on its own model and returns a digest. Unlike spawn_agent, the roster is authored ahead of time, so it deploys as part of a single config.json (one Kubernetes ConfigMap, for instance) rather than being decided at runtime.

{
"model": { "provider": "vertex", "name": "gemini-3.5-flash" },
"subagents": [
{
"name": "cluster",
"description": "Read-only cluster investigator. Delegate GKE reads here.",
"instructions": "@include ./personas/cluster.md", // inline text or an @include chain
"model": { "provider": "vertex", "name": "gemini-3.5-flash" }, // omit to inherit the parent's model
"max_depth": 1, // recursion cap; 0 = substrate default
"tools": ["read_file", "grep"], // built-in allowlist
"mcp": ["gke-readonly"], // MCP servers by name (from mcp.json)
"skills": ["fleet-audit"], // skills by name (from skills/)
"root": "../cluster", // optional: load own AGENTS.md + skills/ + mcp.json from a content root
"budgets": { // optional: bound one delegation, on both doors
"max_turns": 20,
"max_cost_usd": 0.5,
"max_wallclock_seconds": 300
}
}
]
}

Fields. name (unique, required) and description (required — the parent’s model reads it to decide when to delegate). Since v2.9 the pair is injected into the spawn_agent schema itself (name + description in the tool description, and agent constrained to an enum of the configured names unless ad-hoc spawns are enabled), so a parent can route to a subagent its persona never mentions; write the description as when to delegate here, not as a title. instructions is the subagent’s persona, inline or an @include chain expanded through the same scope-confined loader the parent’s memory uses; it lands in the user-instruction layer, so the harness contract stays intact beneath it. model is its own ModelConfig (resolved through the same provider path the parent uses) or omitted to inherit the parent’s model — with two exceptions: anthropic.prompt_cache and builtin_tools carry over from the parent unless the subagent’s own block sets them, so neither a project-wide prompt-cache disable nor a project-wide web-search disable can be lost by declaring a model. builtin_tools merges per field, since its fields are independent tri-states. max_depth caps how deep this subagent may itself nest; independently of it, a subagent may never spawn a subagent it is itself an instance of (self-recursion sits at depth 1, so no cap catches it).

Tool-surface scoping — the nil / list / empty contract. tools, mcp, and skills each narrow one dimension of the parent’s surface, and each obeys the same three-way rule:

Field valueMeaning
omitted (nil)Inherit the parent’s full set for that dimension
non-empty listScope to exactly the named entries (an unknown name is a fail-loud config error)
empty list ([])Grant none of that dimension

So "mcp": ["gke-readonly"] gives the subagent only that one server’s toolset (not the parent’s gke), "mcp": [] gives it no MCP at all, and omitting mcp lets it see every server the parent has. Scoping never re-runs mcp.Build or re-walks skills/ — a scoped subagent reuses the parent’s already-started MCP toolsets and a name-filtered view of the parent’s loaded skills, and every inherited tool still carries the parent’s permission gate, so a subagent cannot escalate past what the operator granted the parent.

One carve-out: delegation is opt-in (v2.9+). Inheriting tools grants the parent’s whole built-in registry except spawn_agent and stop_agent. Omitting tools says “give me the parent’s hardening”, not “give me its authority to build a fleet”, so a subagent that never asked to delegate doesn’t get a delegation surface it can reach for mid-run. To write a deliberate orchestrator subagent, name them: "tools": ["read_file", "spawn_agent"]. Whenever the carve-out fires, the subagent’s startup line says so — subagent "cluster": model=…, tools=inherit, spawn=withheld (spawn_agent+stop_agent; list them in tools: to grant).

Independent content root — the root field. Inline tools/mcp/skills can only narrow the parent’s surface; they cannot give a subagent a persona, skill, or server the parent doesn’t also load. When a delegate needs its own content — for least privilege (a skill the fleet parent must never reason with) or clean separation (sibling recipe trees under one image) — set root to a directory the subagent loads as its own scope:

  • Instructions auto-assemble from <root>/AGENTS.md (plus <root>/AGENTS.d/), with @include confined to the root. An inline instructions field still overrides.
  • Skills load from <root>/skills/ via a dedicated walk — the subagent’s own bundle, independent of the parent’s.
  • MCP servers start from <root>/mcp.json, private to the subagent.

With root set, the nil / list / empty contract for mcp/skills still applies but filters within the root (omit = all of the root’s; a list scopes; [] grants none); tools remains a built-in allowlist resolved against the binary. A relative root resolves against the same base as content_roots (the agents dir when the config was discovered under one, else the cwd); an absolute path passes through. root is operator-declared trust — it is not confined to the project root (the sibling-tree case needs ../cluster) — but a missing or non-directory path is a loud startup error, and the subagent stays bound by the parent’s permission gate: an independent content surface is never a privilege escalation.

Bounding one delegation — the budgets block (v2.9+). A declared subagent is reachable two ways: as a tool the parent calls, and as spawn_agent { agent: "cluster" }. budgets caps one delegation along three independent dimensions and is honored on both doors, because a cap that binds only the door the operator didn’t use is worse than no cap — the config reads as though the subagent were bounded.

FieldTypeDefaultNotes
max_turnsintunsetThe subagent’s own model turns, not the parent’s.
max_cost_usdfloatunsetPriced per turn exactly as the session ledger prices it, under the subagent’s own model.
max_wallclock_secondsintunsetMeasured from the start of the delegation.

Each dimension is independent, and 0 (or an omitted field) means no declared cap: the asynchronous door then falls back to the manager’s defaults (50 turns / $1 / 10m), and the synchronous door stays unbounded, which is what it has always been. Negative values are a config error rather than being clamped — 0 already means “no cap”, so clamping a typo would leave the subagent uncapped while the config said otherwise. A per-spawn spawn_agent override may only tighten what is declared here.

A cap that fires does not fail the delegation. Whatever the subagent produced is returned to the parent, labelled as a partial and naming the cap that stopped it, with a line telling the parent what to do next — re-delegate the remainder with specifics, or finish it itself. Discarding the partial would make the parent pay twice for work it already bought, and the parent is the one holding the goal.

Budgets are the per-delegate complement to the session-wide ceilings: --max-turn-cost-usd and --max-session-cost-usd bound the whole tree (delegated turns count toward them since v2.9), while budgets stops one wandering delegate before it eats that allowance.

The bundled CLI’s REPL recognizes Claude Code-style mid-turn interrupts:

KeyEffect
ESCCancel the current turn. Conversation context is preserved; you can type a redirect.
Ctrl+C (single)Same as ESC. Prints a hint that pressing again exits.
Ctrl+C twice within 1 sExit the REPL cleanly.
Ctrl+DEOF — exit the REPL.

Auto-enabled when stdin is a TTY. Disabled silently for piped / non-TTY use (Ctrl+C falls back to the legacy process-level exit). The REPL’s startup banner reflects which mode is active. See Library API → REPL keybindings for the underlying mechanism.

The permissions.Prompter interface is public:

type Prompter interface {
AskApproval(ctx context.Context, req PromptRequest) (Decision, error)
}

permissions.StdinPrompter(in, out) is the implementation the CLI uses; wire your own if you have a different UI (a TUI, a web prompt, a chat-based approver, etc.). Pass it via permissions.FromConfig(cfg, projectRoot, userRoot, prompter) when constructing the gate.


Extra paths file tools may touch outside the default project root + user home.

FieldTypeDefaultNotes
allowstring[][]Patterns. Exact paths, directory trees ending in /..., or path/filepath.Match globs. Grants both read + write.
allow_pathsobject[][]Typed form: each entry is { "path": "<pattern>", "mode": "r"|"w"|"rw" } (long forms read / write / readwrite also accepted). Composes with allow. Also available as the repeatable --allow-path PATH:MODE CLI flag for one-off grants.

Example:

{
"path_scope": {
"allow": [
"/etc/myapp/...",
"/var/log/myapp.log",
"~/scratch/*.json"
]
}
}

Runtime tuning for the agent loop.

FieldTypeDefaultNotes
max_stepsint50Max tool-call cycles within a single turn before the agent gives up.
max_turn_cost_usdfloat0Per-turn spend ceiling in USD (0 = disabled). When a single turn’s cumulative cost (across all model calls + subtask costs) meets or exceeds this value, the agent emits a cost_ceiling guardrail-trip event and ends that turn; the session is not halted and a further turn starts from a fresh per-turn budget — though nothing starts one on its own, so a -p one-shot ends there with exit 1 (#1140). Three turns in a row that each trip it do halt the session, and that halt needs an operator reset (/guardrail reset, or POST /sessions/{id}/guardrails/reset) — see Cost ceiling. CLI: --max-turn-cost-usd.
max_session_cost_usdfloat0 interactive / 10.00 unattendedSession-level spend ceiling in USD (0 = disabled). Cumulative across every turn including subtasks; same trip + refuse behavior. Recovering from a session trip needs /guardrail reset +<usd> (or additional_budget_usd) — a bare reset re-trips, since the accumulator is already past the bar. When neither this field nor --max-session-cost-usd is set, unattended runs (-p, --no-repl, or a non-TTY stdin) get a $10.00 backstop and interactive runs stay disabled. Set this field to 0 — or pass --max-session-cost-usd=0 — to opt an unattended run back out. CLI: --max-session-cost-usd (overrides this field, including an explicit 0).
session_titlebooltrueGive each session a short label derived from its first prompt, so a session picker (/switch, core-agent-tui’s startup picker, GET /sessions) lists work instead of IDs. Costs one cheap-tier call per session — the small-tier model --agentic-small-model resolves to. Providers with no cheap tier don’t get an extra call at all: the title falls back to the head of the operator’s own prompt. Set false to turn inference off; a session can still be named by hand.
display_namestring""Operator-visible per-deployment label. Rendered in the TUI status-line banner (core-agent · <name> · ◇ model) so operators can distinguish between multiple agent deployments across windows. Empty falls back to the bare wordmark.
descriptionstring""Human-readable summary of what this agent does. Surfaced by /.well-known/agent-card.json when the agent-card endpoint is enabled (see Agent card). Required (via file or --agent-card-description flag) to enable that endpoint.
append_system_promptstring""Operator text appended to the assembled system prompt as its final layer (v2.8, #459). The built-in harness contract, provider quirks, and mode overlay stay intact underneath — this is the encouraged customization path. CLI: --append-system-prompt <text|@file> (flag beats config).
system_prompt_filestring""Path to a file whose contents replace the assembled system prompt wholesale. You lose the harness contract (compaction summaries arrive unexplained; tool-dispatch rules are gone) — tool-use degradation is on you; prefer append_system_prompt. CLI: --system-prompt-file (flag beats config).

Automatic continuation of interrupted turns (design, #539/#558/#559). When a daemon-hosted agent finds a fresh interrupted turn in its committed history — an unanswered user message, a dangling tool call, or a repaired-but-unconsumed tool result — it queues a synthesized system-note turn (“the previous turn did not complete… continue”) instead of waiting for the next human message. The note describes only what was detected, not a cause: interruption is inferred from tail shape and the retry loop below fires it with no restart, so it never claims a “daemon restart” (#615). Continuation turns are queued under the core-agent/auto-continue identity (visible in the audit log; if the note happens to drain into one turn with a concurrently-arriving human message, the human becomes the turn originator and the note text still marks the turn), guarded by the session run lock against double-continuation in shared-DB fleets, and spend under the session’s normal cost ceilings. Self-healing (v2.8, #575): a continuation that fails transiently is retried in-lifetime up to the per-session cap (3 attempts / hour, minutes apart) rather than waiting for a reboot or a human message — retry controls this and is on by default when the feature is enabled. A true crash loop still bottoms out on the crash-loop breaker (3 attempting boots / 10 min → stand down). Deliberately-interrupted turns (POST /interrupt) are never resurrected, and a session whose turn is still generating is left alone (#796): committed history cannot tell an interrupted turn from a running one — both end in an unanswered user message — so the trigger asks the agent whether a turn is in flight before it acts, which is what stops a retry tick that lands inside a long generation from drawing a second, duplicate reply.

On by default when it can apply (#559): a multi-session daemon or a headless --no-repl daemon, both with --session-db (there is nothing to detect against without a durable eventlog). It stays off — silently — for interactive REPL/TUI runs and in-process library use, so those callers are never surprised by unattended token spend; set enabled: false to opt out anywhere. When it turns on by default (rather than by an explicit enabled: true) the daemon logs a one-line notice naming the opt-out knob. Three triggers share the machinery: lazy resume on first touch (multi-session), a bounded boot-time scan over persisted sessions (multi-session), and the startup session of a headless --no-repl daemon (#558 — the examples/gke-deploy shape, checked once at boot). Autonomous runs are unaffected — they have their own checkpoint/resume machinery.

FieldTypeDefaultNotes
enabledbool (tristate)unsetTristate. Omit for the precondition-gated default (on for a multi-session or --no-repl daemon with --session-db, off elsewhere). true forces it on where it can apply (and warns-and-ignores where it cannot); false is a hard opt-out that survives the default flip.
freshnessduration"1h"Only interruptions younger than this are continued; staler ones wait for the next real message. Explicit "0s" disables the window (always continue).
max_per_bootint10Caps how many sessions the boot-time scan continues in one daemon start, oldest interruption first; the rest are logged and resume on touch. The lazy-resume trigger ignores it.
retrybooltrueIn-lifetime self-heal (#575): re-runs the guarded pass on retry_interval so a transiently-failed continuation recovers without a reboot. Still bounded by the per-session cap and the crash-loop breaker. Set false for the old one-shot-per-boot behaviour.
retry_intervalduration"5m"How often the retry driver re-checks. Must be > 0. The per-session single-retry window (10 min) is the effective cadence regardless, so shorter intervals mostly just re-check sooner after a lock frees.

Opt out (the only line most daemons need, now that it defaults on):

{ "agent": { "auto_continue": { "enabled": false } } }

Or tune the on-by-default behaviour — every field is optional:

{ "agent": { "auto_continue": { "freshness": "1h", "max_per_boot": 10, "retry": true, "retry_interval": "5m" } } }

Since #459 the system prompt is assembled from ordered layers, stable → volatile:

#LayerSourceChanges when
1Core contractagent.CoreInstruction (compaction/handover framing, tool-dispatch rules)core-agent release
2Provider quirksselected from the model identifier (Gemini: parallelism mandate; Claude: none)model changes
3Mode overlayinteractive (default) or autonomous via agent.WithModemode changes
4User memorythe instruction loader (AGENTS.md and friends, user → project → per-caller)project/session
5Operator appendappend_system_prompt / --append-system-prompt / agent.WithExtraInstructiondeployment

Later layers win on conflict — your AGENTS.md overrides the built-in communication defaults, but nothing short of system_prompt_file overrides the core contract. The /memory slash (and attach endpoint) reports the active layer stack as its first row.


Session-scoped defaults picked up on startup.

FieldTypeDefaultNotes
task_classstring""Operator-declared task class — picks a bundle of defaults (model tier, compaction threshold, agentic-tools posture, ask mode, built-in tool set, plan-first posture) tuned for the kind of work being done. One of debug, implement, chat, research, review. Empty = no task class (substrate defaults). Explicit config fields + CLI flags always win over the task-class profile. CLI: --task. See Context management → Task class.

Startup safety checks that guard against footguns.

FieldTypeDefaultNotes
small_tier_parentstring"warn"What to do when an interactive session resolves to a small-tier parent model (Flash / Haiku-class — these work well as agentic_* subtask workers but loop and stall as the parent). One of warn / refuse / allow. warn logs a one-line operator notice and proceeds; refuse exits with a config error; allow suppresses the check. Skipped under -p, --yolo, or when the model’s tier can’t be classified. CLI: --small-tier-parent.
watchdogstringmode-dependentBehavioral-watchdog posture, as a ladder: off (no observation), warn (observe + alert the operator), feedback (warn + inject the observation into the model’s next turn as a [watchdog] block), enforce (feedback + stop the agent: a session-scoped Critical halts until /guardrail reset, a turn-scoped one — today no-op-streak alone — ends only that turn, and three turns in a row ended that way escalate to a session halt, #1090). Unset resolves to enforce for unattended runs (-p, --no-repl, or a non-TTY stdin) and warn for interactive REPL/TUI runs. Set it here so a recipe ships its own backstop instead of relying on every invocation passing the flag. CLI: --watchdog (overrides this field). See Context management → Watchdog.
parallel_subagent_writesstring"refuse"Whether two background subagents that can both write may run at once — every in-process agent shares one working directory, and unlike two tool calls inside one agent they are not covered by the mutation serializer. refuse rejects the second spawn with an error naming the agent holding the tree; warn starts it and logs an operator notice; allow disables the check. Classification is on the granted tools, not on an observed collision, so a subagent holding bash counts as a writer even if it only runs tests; read-only spawns are never refused, and neither is a writer spawned after the first has terminated. It does not serialize the parent’s own writes against a subagent’s, nor prevent a logical read-modify-write race. For genuinely parallel writes use spawn_remote_agent, which runs out of process with its own filesystem. See Subagents → They all share one working directory.
bash_search_gatestring"enforce"What happens when the model reaches for a search-shaped shell command — grep/egrep/fgrep/rg/ag/ack or find/fd — while the native grep / glob tools are registered. enforce refuses the call with an error naming the native tool; warn runs it and attaches a notice to the result; allow disables the check. Piped searches (go test ./... | grep -v ok) and find with an action predicate (-delete, -exec) are never gated — the native tools can’t do those. Nor is a binary whose replacement this build didn’t register: put grep in tools.disable and bash grep stops being refused, since the refusal would name a tool the model can’t call. CLI: --bash-search-gate. See Tools → The bash search gate.

Overrides for the automatic context-window compaction trigger. See Context management → Compaction for the full picture.

FieldTypeDefaultNotes
thresholdfloattier defaultFraction of the model’s context window (0-1) at which compaction fires. When unset, threshold_by_tier applies.
threshold_by_tierobjectsee notesPer-model-tier defaults keyed by tier name (frontier, mid, small, …). Lets a shared config target different thresholds per model without a per-project override.

Which parties may declare a task boundary. Distinct from compaction: compaction fires on context-window utilization with no model involvement, so turning checkpointing off does not turn off context reduction. See Context management → Choosing who declares a boundary.

FieldTypeDefaultNotes
modestring"model"model registers the model-facing mark_task_done tool alongside the operator’s /done and the post-turn heuristic. operator keeps /done and the heuristic but withholds mark_task_done, so the model cannot self-declare completion — the posture for a long-lived service, where mark_task_done’s completion-report framing produced sixteen calls in one session and answers that restated closed work. off disables checkpointing entirely; /done then reports no checkpointer. CLI: --checkpoint (overrides this field). The older --no-checkpoint is a deprecated alias for off.

Set it here rather than on the command line so a recipe ships its posture with its content, instead of relying on every invocation and deploy manifest remembering a flag — same argument as safety.watchdog.

{ "checkpoint": { "mode": "operator" } }

Presentation choices for the in-process TUI (core-agent). /theme, /mouse and /permissions layout write back here when used, so a choice made at the keyboard survives the next launch; any field can equally be set by hand.

These fields are read by core-agent only. The remote attach client (core-agent-tui) reads no config file; its equivalent of mouse: false is the --no-mouse flag. It always starts with the overlay permission prompt, and /permissions layout there lasts the session.

FieldTypeDefaultNotes
themestring"auto"One of the reserved buckets auto / dark / light, or any named theme from core-tui’s BuiltinThemes registry (e.g. gopher, google). auto (or empty) lets core-tui detect the terminal background via OSC-11; explicit dark / light skips that query. Validation accepts any lowercase [a-z0-9_-]{1,64}; unknown names fall back to the auto path at launch.
mousebooltrueTerminal mouse capture so the wheel scrolls the chat viewport. When enabled, plain click-drag no longer selects text: the terminal never sees the drag. The bypass modifier is terminal-specific — Shift-drag on most terminals; in VS Code’s integrated terminal (xterm.js), Shift-drag, or Option-drag on macOS, which needs terminal.integrated.macOptionClickForcesSelection on (off by default, and a workspace .vscode/settings.json overrides your user or remote value); and some terminals let you rebind or disable it entirely. Set this to false when you would rather keep native selection than wheel-scroll. /mouse toggles capture at runtime and writes the new state back here, always as an explicit true or false — an absent field means “no opinion”, and the default is on, so a toggle-off that cleared the field would persist the opposite of what you asked for.
permission_layoutstring"overlay"How the TUI draws a permission prompt. overlay (or empty) is a centered modal that dims the chat until you decide; inline draws the prompt as a block in the chat flow, directly under the tool call that asked, so the surrounding context stays visible. Any other value fails config validation. /permissions layout toggles between the two at runtime and /permissions layout inline / /permissions layout overlay set one; either writes the choice back here as an explicit value. A prompt already open keeps its layout — the switch applies from the next one.

Caps tool result size before it enters model context. Prevents a runaway cat /huge.log from blowing through your token budget. The built-in tools (read_file, read_many_files, write_file, edit_file, list_dir, glob, grep, bash, todo) honor these caps; consumer-provided tools should call tools.Truncate(...) to do the same.

FieldTypeDefaultNotes
max_bytesint32768Per-tool-result byte cap.
max_linesint500Per-tool-result line cap.
per_toolobjectsee belowPer-tool overrides keyed by tool name.

Default per_tool overrides (apply to the built-in tools that ship with core-agent):

{
"tool_output": {
"per_tool": {
"bash": { "max_bytes": 65536, "max_lines": 2000 },
"read_file": { "max_bytes": 262144, "max_lines": 5000 },
"read_many_files": { "max_bytes": 262144, "max_lines": 5000 },
"view_file_outline": { "max_bytes": 65536, "max_lines": 2000 },
"glob": { "max_bytes": 32768, "max_lines": 500 },
"grep": { "max_bytes": 262144, "max_lines": 5000 }
}
}
}

(list_dir falls back to its compile-time default of 32 KB / 500 lines when no override is set; the same for any other unlisted tool.)

core-agent ships these tools by default in the bundled CLI; library callers opt in with tools.Build(cfg, gate, tools.Default()). Override per-tool caps with the per-tool block above; add an entry under per_tool for any consumer-provided tool that should follow a non-default cap.


Controls which built-in tools are wired into the bundled CLI. Defaults to the full set; list entries here to turn specific tools off without disabling the whole suite.

FieldTypeDefaultNotes
disablestring[][]Built-in tool names to turn off. Valid: bash, read_file, read_many_files, view_file_outline, write_file, edit_file, delete_file, stat, list_dir, glob, grep, json_query, fetch_url, alert, wait_and_verify, todo, record_plan, sciontool_status. Unknown names cause a startup error.
wait_and_verifyobject{}Bounds for the wait_and_verify poll loop. See below.
call_peerobject{}Off by default. Delegation to peer agents registered with this daemon’s peer hub. See below.
spawn_agentobject{}Bounds for the spawn_agent built-in’s blocking form. See below.

Example — keep everything except shell access:

{
"tools": {
"disable": ["bash"]
}
}

The --disable-tools=bash,write_file CLI flag composes with this list by union — anything disabled in either path is off. To turn the entire suite off, use --no-builtin-tools (which makes tools.disable and --disable-tools moot).

A task class can drop tools too — --task=debug|research|review removes bash. That is a default, so --enable-tools=bash puts it back. --enable-tools cancels the class’s opinion only: it cannot re-enable something listed here or in --disable-tools, and passing both is a startup error rather than a silent win for either side. Naming a tool no class dropped is a no-op; naming a tool that doesn’t exist fails at startup.

FieldTypeDefaultNotes
poll_allowstring[][]Tools that may be polled despite not being classified read-only by the runtime. Use the name the model sees, i.e. namespaced for MCP: <server>_<tool>, joined by a single underscore (gke_get_pod, not gke__get_pod or get_pod). A name that matches nothing is not an error — the poll is simply refused at call time.
max_timeout_secondsint300Ceiling on the tool’s timeout_seconds argument. A larger request is an error, not a silent clamp.
max_attemptsint60Ceiling on the tool’s max_attempts argument.

wait_and_verify refuses to poll anything the runtime can’t classify as read-only, so it can never turn one approved call into sixty mutations. An MCP tool is classified from its server’s readOnlyHint annotation where the server publishes one; a server that publishes nothing leaves every tool on the fail-safe mutating side, and poll_allow is the operator’s explicit, per-tool assertion that a given tool only observes state:

{
"tools": {
"wait_and_verify": {
"poll_allow": ["gke_get_pod", "gke_list_events"],
"max_timeout_seconds": 300,
"max_attempts": 60
}
}
}

Reach for this last. If the server annotates its tools, it has already answered and nothing needs to go here — all 15 tools on GKE’s /mcp/read-only and all 23 on its full /mcp publish a readOnlyHint. If it annotates nothing but is read-only in its entirety, declare that once with read_only: true in mcp.json. poll_allow is what neither reaches: a pollable tool on an unannotated server that also mutates.

Polling adds no authority: each attempt dispatches through the same permission gate, path scope, URL scope, plan-first gating and output caps a direct model call would hit.

Registers a call_peer tool that hands a self-contained prompt to another core-agent daemon and returns its answer. Off by default, and it refuses to boot outside a peer hub: enabled: true without --attach-listen/--attach-unix-socket and --attach-peer-hub is a startup error rather than a tool that registers and then fails every call.

FieldTypeDefaultNotes
enabledboolfalseWire the tool in. Requires attach mode plus --attach-peer-hub.
namestringcall_peerRename the model-facing tool. Must be [A-Za-z_][A-Za-z0-9_]{0,63} — it becomes a function name in the model’s schema, which rules out hyphens and dots. Renaming moves the permission key with it (see below).
descriptionstring(generated)Override the tool description. The default tells the model the roster is dynamic and that calling with an empty peer name returns the current list.
token_envstring""Environment variable holding the bearer token for peer daemons. If set and the variable is unset or empty, the call fails rather than going out anonymously.
timeout_secondsint120Per-call deadline, covering session creation through turn end. Ceiling 900; a larger value is a config error, not a clamp.
max_response_bytesint16384Cap on the peer’s answer. Past the cap the tool stops reading and returns truncated: true.
{
"tools": {
"call_peer": {
"enabled": true,
"token_env": "PEER_TOKEN",
"timeout_seconds": 120
}
}
}

Three properties are true by construction rather than by instruction:

  • The model cannot name a destination. call_peer takes peer and prompt — no URL, no host, no port. The only place a destination comes from is the hub’s peer registry, so a prompt-injected “call http://169.254.169.254/…” resolves to nothing and the error lists the registered peers. This is the same shape as the alert tool’s named-channel argument.
  • No credential is model-visible. The bearer token comes from token_env in the daemon’s environment; it never appears in the tool schema, the arguments, or the transcript.
  • Every call is bounded. One deadline and one byte cap, both operator-set, both enforced in the tool rather than left to the peer’s good behaviour.

Each call opens a fresh session on the peer via POST /sessions, so two callers can’t interleave prompts into one transcript, and the peer’s reply is unambiguously the answer to this request. That makes attach.multi_session.enabled a hard requirement on the peer — a peer without it answers 501, and the tool appends the fix to the error. The delegated turn lives in the peer’s own event log under the returned session_id, which the result carries so an operator can go read it.

Delegation is gated per peer. The permission key is call_peer:<peer-name>, so --deny call_peer:prod-* or an allow-list works the way it does for any other tool, and a name override moves the key with it (ask_operator:ops). Declarative subagents inherit the tool from the parent’s already-gated catalog, so one parent-level policy covers the parent and its subagents together.

Knobs for the spawn_agent built-in itself. The subagents it can reach are declared in the top-level subagents array; this section only bounds the tool. Disable the tool entirely with --no-background-agents.

FieldTypeDefaultNotes
sync_wait_timeoutstring5mHow long spawn_agent {wait: true} holds the parent’s turn open, as a duration string. "0s" removes the cap. Negative is a config error.
{
"tools": {
"spawn_agent": {
"sync_wait_timeout": "15m"
}
}
}

The cap is on the wait, not on the subagent. When it fires the tool returns and the subagent keeps running; its result arrives on a later turn as a pushed report. So the setting trades parent latency against whether the parent sees the answer in the turn that asked for it — and a parent handed a timeout tends to redo the work itself rather than wait, which is the expensive outcome. Five minutes fits typical subagent latencies; a deep diagnostic against a remote API, with hundreds of milliseconds per call and large payloads, routinely runs longer.

"0s" is honored as no cap rather than snapped back to the default: the wait then ends when the subagent finishes on its own turn and wall-clock budgets, or when the parent’s context is canceled. That is a reasonable choice for a recipe whose subagents carry tight budgets of their own, and a poor one otherwise — those budgets become the only bound left.

CLI equivalent: --subagent-sync-wait=15m, which beats the config field. Both apply to the daemon’s own manager and to every per-tenant manager a multi-session daemon stands up, so a tenant session can’t drift from the daemon’s cap.


Configures the echo and scripted mock providers, plus the orthogonal recording wrapper. See Providers → Echo and Providers → Scripted for the full story; this section is the schema.

FieldTypeDefaultNotes
scriptstring""Path to a JSONL transcript. Required when model.provider: scripted.
strictboolfalseScripted: assert each incoming request’s Contents JSON-equal the recorded request. Catches prompt-construction regressions.
recordstring""Write a JSONL recording of every LLM turn to this path. Works with any provider, not just the mocks.

Example — record a real Gemini session for later replay:

{
"model": { "provider": "gemini" },
"mock": { "record": "fixtures/last-session.jsonl" }
}

Example — replay it under tests:

{
"model": { "provider": "scripted" },
"mock": { "script": "fixtures/last-session.jsonl", "strict": true }
}

CLI flags --script, --script-strict, and --record-to override the corresponding fields. --record-to is the orthogonal one — it’s safe to combine with any provider.


OpenTelemetry exporter config. Off by default — a fresh invocation makes zero outbound spans. See the OpenTelemetry concept page for enabling, span tree, K8s deployment, and pitfalls.

FieldTypeDefaultNotes
exporterstringnoneOne of none, console, otlp.
endpointstring""OTLP endpoint when exporter: otlp (or set via standard OTEL_EXPORTER_OTLP_ENDPOINT env).

Console mode prints span JSON to stderr — useful for local debugging. OTLP mode honors all the standard OTEL_* env vars.

Metrics run on a separate pipeline from traces (the daemon builds its own MeterProvider — ADK-go has none). Off by default. See the Metrics concept page for the instrument inventory, PromQL samples, and caveats.

FieldTypeDefaultNotes
exporterstringnoneOne of none, otlp, prometheus, both. Env OTEL_METRICS_EXPORTER overrides.
prometheus_addrstring:9464Scrape endpoint bind address when prometheus/both. --metrics-addr overrides.
session_labelsbooltrueStamp session.id / app.name / user.id on usage metrics. Set false to aggregate across sessions before export — for fleets where many short-lived sessions × models would blow up series cardinality. When false, core_agent.session.duration is not emitted (an aggregated wall-clock is meaningless) and the cost series’ priced flag is the AND across sessions.

Every outbound HTTP the daemon makes (Vertex / Anthropic / Gemini / MCP HTTP / attach peer calls) is wrapped in otelhttp and stamped with the W3C traceparent header, threading the current span’s trace ID into upstream requests. When the OTEL exporter is off, header injection still fires but produces no-op values — hosts running their own tracer above the daemon can rely on continuity without needing to enable the built-in exporter. Inbound attach requests already extract traceparent; the propagation change closes the outbound half of the loop.


Governs the pricing-catalog refresh — distinct from model.pricing above (per-model rate overrides). Defaults: refresh enabled, daily cadence, LiteLLM upstream.

FieldTypeDefaultNotes
refreshbooltruePull the upstream pricing JSON into ~/.core-agent/pricing.json’s external section once per day on startup. Set to false for air-gapped pods or any environment where outbound network is blocked / undesirable. CLI flag --no-pricing-refresh always wins.
sourcestringhttps://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.jsonUpstream URL to fetch. Override for mirrors or internal pricing services. The fetched JSON must match LiteLLM’s schema (per-token costs + mode field).

The refresher uses If-None-Match against a stored ETag so re-fetches transfer zero bytes when upstream hasn’t changed. Network failures are non-fatal: the existing cache stays in place, a one-line warning (“using N-day-old cache; network: …”) goes to stderr, and the session continues.

From the in-process TUI, two slash commands give operators direct control without leaving the chat:

  • /pricing refresh — force an out-of-cycle fetch from pricing.source (ignores the 24h cadence). Useful right after a provider price change. Result lands in the chat scrollback: “Refresh: updated 247 models from upstream” / “Refresh: upstream unchanged” / “Refresh failed; using N-day-old cache”.
  • /pricing set <model> <input_per_mtok> <output_per_mtok> — write a per-model rate to ~/.core-agent/pricing.json’s manual section atomically + rebuild the live catalog so it takes effect immediately. Example: /pricing set gemini-3.5-flash 0.075 0.30. The manual section round-trips intact across the daily refresh (the auto-fetcher only rewrites external).

Either command takes effect on the next turn — both the rate /pricing reports and the rate the running session’s ledger bills at. Turns already recorded keep the price they were billed at; a refresh is not a re-pricing of history. Before v2.9 the long-lived billing paths (the REPL, the wake loop, autonomous runs, and per-session pricing under --multi-session) captured their rate once at startup, so a mid-session refresh moved the reported number while the bill stayed on the old rate (#930).

Governs which URLs the fetch_url built-in is allowed to reach. Same Allow/Deny grammar as path_scope but for HTTP hosts instead of filesystem paths. Deny always wins over Allow. An empty allow is default-deny — fetch_url is not registered as a tool at all when no allowlist is configured, so the model can’t even attempt a network call without an operator-declared scope.

FieldTypeDefaultNotes
allowstring[][]Host patterns. github.com (exact), *.googleapis.com (subdomain wildcard), * (any host), http://localhost:* (HTTP + any-port opt-in). HTTPS by default — prefix with http:// to allow plain HTTP for that pattern only.
denystring[][]Patterns that override allow on overlap (same grammar).
max_body_bytesint65536Cap on the response body returned to the model. Per-call max_bytes argument can lower this, never raise it.
timeout_secondsint30HTTP timeout per call.
headersobject{}Per-host header bundles. Map of host-pattern → header-name → value template. Values pass through os.ExpandEnv at request time, so rotated env vars take effect on the next fetch without a restart. Most-specific pattern wins (longer wins; exact match beats wildcard). The model never sets headers directly — keeps credential exfiltration off the tool-argument surface.
allow_metadata_endpointsboolfalseOpts back into fetching link-local / cloud-metadata addresses (169.254.0.0/16 incl. 169.254.169.254, fe80::/10, AWS IMDSv6 fd00:ec2::254, IETF special-purpose 192.0.0.0/24 incl. 192.0.0.192), which are otherwise hard-blocked in every permission mode regardless of the allowlist. Leave off unless you are deliberately building a metadata-service integration.
proxystring""Outbound proxying for fetch_url. Empty (default) = no proxy — the standard HTTP_PROXY/HTTPS_PROXY env vars are deliberately ignored, because with a proxy in the path, hostname targets are resolved at the proxy, outside the SSRF guard’s resolve-validate-pin dial. Set "env" to honor the env vars, or a fixed http://, https://, or socks5:// URL to route every fetch through it. Either non-empty value is an explicit decision to delegate private/metadata SSRF policy for hostname targets to the proxy; literal-IP targets are still screened locally on the initial URL and every redirect hop.

Worked example:

{
"url_scope": {
"allow": [
"api.github.com",
"*.googleapis.com",
"*.svc.cluster.local",
"http://localhost:*"
],
"deny": ["*.internal.evil.com"],
"max_body_bytes": 131072,
"timeout_seconds": 30,
"headers": {
"api.github.com": {
"Authorization": "Bearer ${GITHUB_TOKEN}",
"Accept": "application/vnd.github+json"
}
}
}
}

The allowlist matches host names; a second layer vets the addresses those names resolve to, at dial time:

  • Link-local / cloud-metadata ranges are always blocked — 169.254.0.0/16 (including the 169.254.169.254 metadata service), fe80::/10, the AWS IMDS IPv6 address fd00:ec2::254, and the IETF special-purpose block 192.0.0.0/24 (including the 192.0.0.192 metadata endpoint some clouds use) are refused in every permission mode (yolo included), no matter what the allowlist says. The only opt-out is allow_metadata_endpoints: true.
  • Loopback / private / special-purpose ranges need an exact-host opt-in — 127.0.0.0/8, ::1, RFC1918 (10/8, 172.16/12, 192.168/16), CGNAT 100.64/10, IPv6 ULA fc00::/7, 0.0.0.0/8 and the unspecified addresses 0.0.0.0/::, NAT64 prefixes (64:ff9b::/96, 64:ff9b:1::/48), benchmarking 198.18.0.0/15, broadcast, and multicast are refused unless the request host appears in allow as an exact (non-wildcard) entry. Listing internal-api.corp:8443 explicitly opts that host in; a broad * or *.svc.cluster.local wildcard does not unlock private ranges.
  • DNS rebinding is closed by IP pinning — the host is resolved once, every returned IP is validated (one bad IP rejects the whole set), and the connection is dialed to one of those same vetted IPs. TLS SNI and the Host header still carry the original hostname; only the TCP dial target is pinned. Every redirect hop re-runs the full validation + pinning.
  • Proxies are explicit-only — with proxy unset, ambient HTTP_PROXY/HTTPS_PROXY env vars are ignored so hostname resolution can’t silently move to a proxy outside the guard. Setting proxy: "env" or a fixed proxy URL delegates the hostname-target SSRF policy above to that proxy — pick a proxy that enforces its own egress rules.

Each fetch emits a tool/fetch_url event into the eventlog with structured metadata (url, final_url, status, content_type, bytes, truncated), so an audit query can answer “what URLs did this agent touch, when, and what came back” without parsing tool output. Composes with the permissions gate — write permissions.allow: ["fetch_url:github.com/*"] to gate per-host even within the URL allowlist.

What’s not in fetch_url (by design):

  • No POST / forms / uploads — GET only. Use a dedicated MCP server for structured POSTs where the operation can be schema-typed.
  • No JavaScript execution — use the playwright MCP for dynamic pages.
  • No cookie persistence — each call is stateless.
  • No model-set auth headers — headers come from url_scope.headers + env expansion only. The model picks the host; the operator picks what auth ships with the request.

CLI conveniences (no config edit needed):

  • --allow-url-host="github.com,*.googleapis.com" — appends to url_scope.allow for the current invocation.
  • --disable-tools=fetch_url — turns the tool off even if an allowlist is configured.

See fetch-url-design.md for the full decision record.


A top-level array of external directories to trust as additional instruction and skill scopes. It lets a recipe run an unmodified external agent tree — a checkout of another repo’s agents/… layout, for example — without vendoring a copy into .agents/. Nothing is added to the external tree; core-agent reads its AGENTS.md / AGENTS.d/ and skills/ in place.

{
"version": 1,
"model": { "provider": "vertex", "name": "gemini-3.5-flash" },
"content_roots": ["../kube-agents/agents/platform"]
}
  • Paths resolve relative to the agents dir (the directory holding config.json), or the working directory when no .agents/ was discovered. Absolute paths are used as-is.
  • Each root is its own trusted scope. An @include inside a root resolves within that root; @includes or symlinks that escape the root are still rejected. The operator opt-in relaxes only the ban on reaching across trees — not confinement within one.
  • Ordering and precedence. Instruction scopes concatenate in the order user → home-agents → content_roots (listed order) → project → per-caller, so external personas appear ahead of the project overlay. Skills follow first-declarer-wins at precedence project > content_roots (listed order) > home-agents > user, so a project skill shadows an external one of the same name.
  • A missing root is a loud error (an operator typo shouldn’t silently shrink the system prompt); a root without a skills/ subdirectory simply contributes no skills.
  • MCP is not auto-loaded from an external tree. Translate its servers once into the recipe’s own mcp.json; the external tree stays untouched.

CLI convenience (no config edit needed):

  • --agents-content-dir <dir> — repeatable; each value is an additional content root, merged after the config’s content_roots and resolved the same way. Example: core-agent -c .agents/config.json --agents-content-dir ../kube-agents/agents/platform.

See external-content-root-design.md for the full decision record.


Registers the webhook destinations the alert built-in is allowed to fire. Like fetch_url, the tool is default-deny: with no targets configured the alert tool is not registered at all, so the model can never notify an operator-unapproved endpoint. SSRF is impossible by construction — the tool has no arbitrary-URL argument. The model picks a target by name from this registry; an unknown name is rejected, not dialed.

The tool lets a headless daemon escalate — incident summaries, “I finished / I’m stuck” pings, decision notifications — without shelling out or standing up a separate MCP server.

FieldTypeDefaultNotes
targetsobject[][]The registered destinations. Empty → the alert tool is not registered. A target whose url_env (or auth env) is unset in the process is dropped at startup — see Undeliverable targets. See the per-target fields below.
rate_limit_per_targetstring""Optional cap applied independently to each target. Accepts "30s" (one every 30s), "1/30s", "5/min", "100/hour". Empty → unlimited. In-memory only (per process; resets on restart).

Each entry in targets:

FieldTypeDefaultNotes
namestring—Required. Unique per registry; [A-Za-z0-9_-], 1–64 chars. This is the identifier the model fires and the string the permissions gate scopes on (alert:<name>).
kindstring"webhook"Reserved for future transports; only ""/"webhook" are accepted today.
urlstring""The destination URL (http/https). Mutually exclusive with url_env — set exactly one.
url_envstring""Name of the env var holding the destination URL. Prefer this for secret webhook URLs (e.g. a Slack Incoming Webhook) so the secret lives in your secret manager, not this file. Checked at startup (an unset value drops the target) and re-resolved at call time.
templatestring—Required. Payload shape: generic, switchboard, or one of the three service templates slack / discord / pagerduty_events_v2. Anything else is rejected at load.
conversationstring""Required for switchboard, rejected for every other template. The gateway’s conversation key — for Slack a channel ID (C0123) or channel:thread_ts to post into an existing thread. Routing config, so it lives here rather than in the model’s arguments. A literal, not a *_env: a conversation key is not a secret.
authobjectnullOptional auth header, resolved from env at call time (rotates without a restart). One of: bearer_env (→ Authorization: Bearer <env>), or basic_env_user + basic_env_pass (→ HTTP Basic). The model never sets auth — the operator picks what ships. Required as bearer_env for switchboard (its ingress token) and for pagerduty_events_v2 (the integration’s routing key, which is sent in the body — no Authorization header goes out for that template). slack and discord normally need no auth at all: the Incoming Webhook URL is the credential.
descriptionstring""Free text surfaced to the model in the tool description so it can match the right target to the situation.

The generic template posts application/json:

{ "level": "critical", "summary": "…", "details": { "…": "…" } }

details is omitted when empty. No timestamp is included by design — the eventlog’s tool/alert record is the authoritative time source.

switchboard posts to go-steer/switchboard’s outbound message API (POST /v1/messages) instead of straight at a chat platform:

{ "conversation": "C0123", "text": "**[critical]** checkout-svc has no healthy endpoints\n- `cluster`: prod-us-east\n- `incident`: INC-42" }

It is a destination class, not a service format, which is what separates it from the slack template rather than duplicating it. One template covers every platform the gateway bridges — Slack and Google Chat today, more later — because the gateway owns the per-platform translation. So text is plain CommonMark: the level in bold, then one bullet per details entry with the key in backticks, sorted so the same alert produces the same body every time. Non-string detail values are rendered as compact JSON.

What routing through the gateway buys over a direct webhook post:

  • the message lands in a thread the gateway can address later — edit it, append to it, roll it over as an incident develops;
  • platform rendering, chunking and markdown translation are solved there once, for every platform;
  • a human can reply, and the reply is routed back into a session rather than into a channel nobody is reading.

That last one is why the POST also carries an X-Agent-Session: <session id> header naming the session the alert was fired from, so the gateway can bind the thread it creates to that session. It is a header rather than a body field because switchboard’s ingress decodes strictly (an unrecognised body field is a 400, not a no-op), and an HTTP header is the part of a request that is defined to be ignorable when unknown — so the same binary talks to a gateway that reads it and one that doesn’t. Only the switchboard template sends it; a third-party webhook has no business learning a session id.

Two things the load-time validator insists on, because neither can be supplied later and both otherwise fail at 3am:

  • conversation — the gateway needs somewhere to put the message, and the model has no say in where that is. Whitespace and control characters are rejected: it is an opaque platform key, and switchboard rejects those too.
  • auth.bearer_env — switchboard’s ingress refuses to start without a token, so a target without one can only ever 401. That token is deliberately distinct from the daemon token: different direction, different trust. Because it is a *_env, an unset value also drops the target at startup under the rule below.
{
"name": "sre-chat",
"url_env": "SWITCHBOARD_URL",
"template": "switchboard",
"conversation": "C0123",
"auth": { "bearer_env": "SWITCHBOARD_INGRESS_TOKEN" },
"description": "the #sre-oncall thread; a human can reply here and the reply comes back into this session"
}

The tool stays fire-and-forget either way: it posts and reports the status code. Nothing in core-agent listens for the reply — a reply comes back through the attach API’s POST /sessions/{sid}/inject, and the responder needs to be a contributor on that session to use it (see Multi-session → ACLs).

slack, discord and pagerduty_events_v2 post directly at one platform, in that platform’s own wire format. They are the counterpart to switchboard, not a duplicate of it: a direct post reaches a channel or an incident queue and nothing comes back, whereas the gateway gives you a thread a human can reply into. Pick a service template when the destination is the notification; pick switchboard when the escalation should start a conversation.

All three take the same level / summary / details the model already passes, and all three render details in sorted key order, so two identical alerts produce identical bytes.

slack — Block Kit at an Incoming Webhook. A header block carries [level] summary; details become two-column section fields, ten per section:

{
"text": "[warning] checkout-svc unresolved past budget",
"blocks": [
{ "type": "header", "text": { "type": "plain_text", "text": "[warning] checkout-svc unresolved past budget" } },
{ "type": "section", "fields": [
{ "type": "mrkdwn", "text": "*attempts:*\n3" },
{ "type": "mrkdwn", "text": "*cluster:*\nprod-us-east" }
] }
]
}

text is set as well as blocks because Slack uses it for the phone-notification preview — a blocks-only message previews as “This content can’t be displayed”. Detail keys and values are entity-escaped (&, <, >): they are model-supplied data, and an unescaped < opens a Slack link that swallows the rest of the value, so a perfectly ordinary <none> would render as nothing at all.

discord — one webhook embed, colour-coded by level (blue / amber / red / green for info / warning / critical / resolved). details become inline fields. A summary too long for the 256-character embed title is kept in full in the description rather than cut:

{ "embeds": [ { "title": "[critical] checkout-svc has no healthy endpoints", "color": 15158332,
"fields": [ { "name": "cluster", "value": "prod-us-east", "inline": true } ] } ] }

pagerduty_events_v2 — an Events API v2 enqueue at https://events.pagerduty.com/v2/enqueue:

{
"routing_key": "<from auth.bearer_env>",
"event_action": "trigger",
"dedup_key": "INC-42",
"payload": {
"summary": "checkout-svc has no healthy endpoints",
"source": "prod-us-east/checkout-svc",
"severity": "critical",
"custom_details": { "replicas": 0 }
}
}

Three things worth knowing before you wire it:

  • The routing key goes in the body. Events v2 never reads Authorization, so core-agent doesn’t send one for this template — the key you put in auth.bearer_env is written to routing_key and nowhere else. It is still auth.bearer_env rather than a field of its own so that an unset key drops the target at startup under the rule below; PagerDuty is the last destination that should be advertised to a model and then silently unable to page.
  • level: "resolved" needs details.dedup_key. It becomes event_action: "resolve", and PagerDuty has no way to know which incident to close without the key the triggering alert used. The tool keeps no state between calls, so the caller supplies it; omitting it is a tool error naming the field, not a rejected page.
  • Two details are promoted. details.source becomes payload.source (PagerDuty requires the field; it defaults to core-agent) and details.dedup_key becomes the top-level dedup_key. Both are removed from custom_details once promoted.

Every field these templates emit is capped at the limit the service documents — Slack’s 150-character header and 50 blocks, Discord’s 25 fields and 6000 characters, PagerDuty’s 1024-character summary — counted in characters, not bytes. Exceeding one is a rejection of the whole message, so the truncation happens here rather than at the destination; when details are dropped to fit, the message says how many.

Worked example:

{
"alerts": {
"rate_limit_per_target": "1/30s",
"targets": [
{
"name": "slack-oncall",
"url_env": "SLACK_ONCALL_WEBHOOK",
"template": "slack",
"description": "on-call channel; use for anything needing a human now"
},
{
"name": "pagerduty",
"url": "https://events.pagerduty.com/v2/enqueue",
"template": "pagerduty_events_v2",
"auth": { "bearer_env": "PAGERDUTY_ROUTING_KEY" },
"description": "pages the on-call engineer; use ONLY for a genuinely critical, human-required incident"
},
{
"name": "audit-sink",
"url": "https://audit.internal.example.com/hook",
"template": "generic",
"auth": { "bearer_env": "AUDIT_SINK_TOKEN" },
"description": "append-only audit log; fire on every significant decision"
}
]
}
}

Per-target gating composes with the permissions gate: write permissions.allow: ["alert:slack-oncall"] to let the agent fire only that target, or ["alert:*"] for all. Auth material never appears in the tool arguments or the audited call — only the target name, level, summary, and details do, and the result carries just the status code and duration (never the response body).

Undeliverable targets are dropped at startup

Section titled “Undeliverable targets are dropped at startup”

A target that reads its URL or auth from the environment is only real if that environment variable is actually set. A process’s environment is fixed when it starts, so “unset at startup” means “unset for this process’s whole life” — editing the Secret needs a pod restart either way.

So at startup core-agent partitions the registry:

  • Targets whose url_env, auth.bearer_env, or auth.basic_env_user/basic_env_pass resolve to empty are dropped: they don’t appear in the alert tool’s description, and firing them returns unknown target.

  • If no target survives, the alert tool is not registered at all — same default-deny shape as an empty targets list.

  • Each drop prints a line to stderr naming the target and the variable:

    core-agent: alerts: target "oncall" is not deliverable (url_env "ONCALL_WEBHOOK_URL" is unset or empty); dropped from the alert tool
    core-agent: alerts: no deliverable targets; the alert tool is NOT registered — the agent has no escalation path

The reason is that the alternative is worse. A target that is advertised but can’t fire tells the model it has an escalation path, and the model finds out otherwise at the single moment it matters — the end of an incident it couldn’t resolve, with the page already assumed sent. Better for the agent to know it has no pager and say so in its summary than to believe it has one.

The call path re-resolves both URL and auth anyway, so a variable that disappears mid-process fails closed rather than sending to an empty URL or without a token.

Two consequences worth planning for:

  • A recipe’s prose can’t be filtered this way. If your AGENTS.md or a skill says “escalate via alert(target: "oncall")”, that instruction survives the target being dropped. Give it a fallback (“if alert is unavailable, put the escalation in the summary”) so the turn still ends usefully.
  • Recipe validation ignores this rule. examples/internal/recipecheck treats a configured target as reachable regardless of the environment, because whether the Secret is mounted is a property of the deployment, not of the recipe.

CLI convenience:

  • --disable-tools=alert — turns the tool off even when targets are configured.

Default values for the attach-mode listener and the peer-registration client. Every field below is also exposed as a --attach-* CLI flag: names follow the --attach-<kebab-case-field> convention (unix_socket → --attach-unix-socket, peer_hub → --attach-peer-hub, register_to → --attach-register-to, and so on). The flag wins when explicitly set, otherwise the config value applies, otherwise the zero value. This section exists for K8s-style deployments where the same settings would otherwise be repeated on every invocation.

String fields are passed through os.ExpandEnv so per-pod values like "https://${POD_IP}:7777" can live in a shared ConfigMap and resolve to the right address at startup.

FieldTypeDefaultNotes
listenstring""Address the attach HTTP server binds to (e.g. "127.0.0.1:7777"). Empty → server off. Mutually exclusive with unix_socket. Implies --session-db at runtime (the broadcaster pumps from the event log, so a durable one is a precondition, not a preference); pass --session-db-path to choose where it lands. Non-loopback addresses (":7777", "0.0.0.0:7777", …) refuse to start without authentication — set token_env (or mTLS via client_ca, or enforced multi-session auth). Tokenless loopback starts but logs a loud warning.
unix_socketstring""Bind path for the Unix-socket transport (e.g. "/var/run/core-agent.sock"). Same SSE protocol; useful for local dev and Cloud Run sidecar shapes.
tls_certstring""TLS server certificate (PEM path). Pair with tls_key to enable HTTPS.
tls_keystring""TLS server key (PEM path).
client_castring""CA bundle (PEM path) for client-certificate verification (mTLS). When set, clients must present a cert signed by this CA.
token_envstring""Env var name (not the secret) holding the bearer token clients must present in Authorization: Bearer <token>. The secret itself never lives in this file — mount it via your secret manager.
readonlyboolfalseDisable POST /inject and POST /wake. Read endpoints (GET /sessions, GET .../events) stay open.
peer_hubboolfalseEnable peer-registration endpoints (POST /peers, GET /peers, POST /peers/<id>/heartbeat, DELETE /peers/<id>) on the listener — this agent becomes a discovery hub.
peer_state_filestring""Path to a JSONL file that makes the hub’s registry durable across restarts (#595). Snapshotted on every register/heartbeat/deregister/prune and reloaded at startup, so a hub restart doesn’t blank the fleet until every peer’s next heartbeat fails (a 20–60s blackout otherwise). Requires peer_hub; setting it without one is a startup error. Leases that expired while the hub was down are dropped rather than resurrected. The file holds registration IDs — the deregistration capability — so it is written 0600 and its directory should match. Put it on a volume that outlives the pod; a path that exists but can’t be read, or a directory that can’t be written, fails the boot rather than silently falling back to in-memory.
register_tostring""Hub URL this agent registers with on startup (e.g. "https://hub.default.svc:7777"). Empty → no registration. Heartbeats automatically until shutdown.
register_endpointstring""Reachable URL the hub records for this agent. Required when register_to is set, since the agent’s own listen value is commonly 0.0.0.0 and not directly reachable. Typically "https://${POD_IP}:7777".
register_namestringhostnameName to register under. Defaults to os.Hostname() when empty. Name-based upsert: a restart re-uses the slot rather than orphaning the old entry.

Worked example for a K8s deployment ConfigMap:

{
"version": 1,
"model": { "provider": "vertex", "name": "gemini-3.7-flash",
"vertex": { "project": "my-proj", "location": "us-central1" } },
"attach": {
"listen": "0.0.0.0:7777",
"tls_cert": "/etc/attach/tls.crt",
"tls_key": "/etc/attach/tls.key",
"client_ca": "/etc/attach/ca.crt",
"token_env": "ATTACH_TOKEN",
"register_to": "https://core-agent-hub.default.svc:7777",
"register_endpoint": "https://${POD_IP}:7777",
"register_name": "monitor-${HOSTNAME}"
}
}

See Attach mode TUI for the protocol and CLI overview, including the --attach-token=<envvar> flag that pairs with token_env.

Caps how long the attach listener’s graceful HTTP shutdown waits for in-flight requests after SSE streams are hung up. Duration string, must be greater than zero; omit the field to keep the default "5s". This counts toward the daemon’s total teardown budget — keep it comfortably under the supervisor’s kill timeout (K8s terminationGracePeriodSeconds, default 30s).

{ "attach": { "shutdown_timeout": "10s" } }

Tunes the per-caller token bucket that bounds the cost-bearing attach endpoints — the five slash ops (compact, done, btw, subagent, replan), POST /sessions, and pricing/refresh. On by default; reads, /events streams, /inject, and /wake are never limited. Callers are the server-verified identities (bearer table, validated proxy assertion, or the single anonymous bucket in single-user mode). Over-limit requests get 429 with a Retry-After header.

FieldTypeDefaultNotes
per_minuteint10Sustained per-caller rate. 0 keeps the default; negative fails validation.
burstint5Bucket size — how many back-to-back calls before the sustained rate applies.
disabledboolfalseTurns enforcement off entirely. Prefer raising the limits over disabling on multi-session daemons.
{ "attach": { "cost_rate_limit": { "per_minute": 60, "burst": 15 } } }

Nested under attach, enables the multi-tenant surface where distinct callers each drive their own session on the same daemon. See Multi-session for the operator narrative; this table is the field reference.

FieldTypeDefaultNotes
users_dirstring""Directory holding per-caller overlays (<usersDir>/<callerIdentity>/.agents/). Empty disables the per-caller overlay path; the daemon behaves as single-user.
auth.kindstring""Authentication scheme: bearer_table (default when table_file is set), asserted_caller_header, or "" (single-user / no per-caller auth).
auth.table_filestring""Path to the bearer-token → identity JSON table when auth.kind == "bearer_table". Reloaded on file modification.
admin_identitiesstring[][]Caller identities granted the admin surface (/sessions/* cross-caller reads, DELETE /sessions/{sid} against any owner, etc.). Non-admin callers only see their own sessions.
allow_anonymousboolfalseAccept requests with no caller identity as the daemon-wide anonymous user. Off by default; useful for smoke tests.
default_identitystring""Identity used when the caller doesn’t present one AND allow_anonymous is off. Empty rejects the request.
proxy_identitiesstring[][]Identities trusted to set X-Asserted-Caller on behalf of others (typical: a front-door proxy that has already authenticated).
asserted_caller_headerstring"X-Asserted-Caller"HTTP header the daemon reads for the pre-authenticated caller identity when the request came from a proxy_identities member.
session_idle_timeoutduration"0s"Reap sessions with no activity for this long. 0s = never reap; interactive daemons typically leave off, long-lived multi-tenant daemons might set "30m" to prevent unbounded growth.

core-agent finds your config like this:

  1. Walk up from the current working directory looking for a folder named .agents/. First match wins.
  2. Read <found>/config.json if present. Missing file → use built-in defaults.
  3. Merge the loaded JSON over config.DefaultConfig() — unspecified fields keep their defaults. Unknown fields are tolerated for forward compatibility.
  4. Validate the merged result. Bad provider name, missing required field, or wrong schema version → fail fast at startup.

Override discovery with the CLI’s -c <path> flag, which reads the file directly and treats its parent directory as the agentsDir for MCP / skills resolution.

The walk-up has no boundary other than the first hit. A .agents/ directory anywhere above your working directory is picked up with no error, no prompt and no confirmation — and it brings that project’s model, permission mode, cost ceilings and tool surface with it. A script that runs core-agent from a subdirectory of a project whose root carries a .agents/ is running under that project’s recipe, whether or not it meant to. Nothing in the run says so; it just behaves like a different agent.

To see which config actually loaded, read the startup summary’s first line. A config that came from the walk-up is labelled:

core-agent: config: source=/home/me/some-other-proj/.agents/config.json (via .agents/ discovery)

If that path isn’t the one you expected, everything downstream of it — model, permissions, budgets, skills, MCP servers — is someone else’s.

The fix is to pin the config on any run that must not inherit:

Terminal window
core-agent -c ./.agents/config.json -p "…"

--agents-dir is not a substitute. The config is loaded by discovery before that flag is applied (see the precedence table below), so it moves the skills, plans and content roots while leaving the model, permissions and budgets behind — the half that changes what the agent is allowed to do. Pass -c as well.

Where agentsDir comes from (v2.9.0-dev, #945)

Section titled “Where agentsDir comes from (v2.9.0-dev, #945)”

The agentsDir is the directory MCP servers, skills, plans, env.yaml and relative content_roots and subagent root paths resolve from. It is decided independently of the config’s own location, in this order:

PrecedenceSourceStartup-summary label
1--agents-dir <dir>via --agents-dir
2filepath.Dir(-c) — the directory holding the file -c namedderived from filepath.Dir(-c)
3The .agents/ directory discovery walked up to from the cwdvia .agents/ discovery

Before --agents-dir existed, rule 2 was unconditional: pointing -c at a config outside your content tree silently moved the whole tree with it, and the only workarounds were symlinks or a cd. Use --agents-dir when the two genuinely live apart — a read-only mounted config next to a writable content volume, for example:

Terminal window
core-agent -c /etc/core-agent/config.json --agents-dir /var/lib/core-agent/.agents --attach

A --agents-dir that does not exist, or that names a file rather than a directory, is a fatal startup error (exit 2), not a warning. Everything downstream of the value fails silently when it points nowhere — no MCP servers, no skills, no env.yaml, nowhere for record_plan to write — so a typo would otherwise produce a daemon that starts cleanly and knows nothing.

Passing --agents-dir without -c while a config.json is discovered in a different tree is legal but prints a warning: settings then come from one tree and content from another, and the symptom (settings apply, content does not) looks like content that failed to load rather than content that was never looked for. Pass -c as well to say you meant it.

There is no config.json equivalent — the flag decides where content is read from, so it cannot itself be read from that content.

Every invocation prints a compact one-line-per-item summary to stderr right after config resolution — the exact model + provider, the source of the config (.agents/ discovery vs. -c <path> vs. built-in defaults), the resolved agentsDir, and follow-up notices for MCP servers, skills, and multi-session auth. Use this to confirm at a glance which config actually loaded when a deployment behaves unexpectedly.

core-agent: config: source=/home/me/proj/.agents/config.json (via .agents/ discovery)
core-agent: agentsDir: /home/me/proj/.agents (via .agents/ discovery)
core-agent: model: claude-opus-5 provider=anthropic-vertex
core-agent: mcp: 2 server(s) loaded — github(ok), grafana(ok)
core-agent: skills: 3 loaded — code-review, security-review, incident-triage

Structured JSON emission for machine consumers isn’t wired today; if you need it, parse the stderr lines or open an issue.

Add -i "seed prompt" to seed the first turn of an interactive session and stay in the REPL/TUI. See the interactive quickstart.


config.Save(path, cfg) writes via temp file + rename so a partial write can never leave a corrupt config.json on disk. Use it when you build tooling that mutates config (e.g. an init-style command, or a /permissions slash command in a downstream consumer).


A handful of features are CLI-flag-only, with no config.json field today (consumers that want them per-project typically wrap the CLI in a script):

FlagDocumented at
-i / --interactive-prompt=TEXTInteractive quickstart → Seed the first turn — submit an initial turn on startup and stay in the REPL/TUI. Mutually exclusive with -p; incompatible with --no-repl.
--allow-path=PATH:MODEPermissions → Path scope — grant r / w / rw access to a tree outside project + user-home roots (repeatable).
--agents-dir=DIRWhere agentsDir comes from — point MCP / skills / plans / content roots at a tree the config does not live in. Deliberately not config-backed.
--ask=stdin|auto|offLibrary API → Prompter
--session-db, --session-db-pathSessions and event log
--color=auto|always|neverLibrary API → Color
--record-to, --script, --script-strictProviders → Mock providers
--no-tuiGetting started → Multi-turn TUI — skip the Bubble Tea TUI even on a TTY (slim build / scripts / unusual terminals)
--log-file=PATHMirror daemon stderr diagnostics to PATH in addition to the terminal. Empty or - keeps today’s stderr-only behavior. Recommended: /tmp/core-agent.log so startup errors (MCP init, model resolution, watchdog notices) survive the TUI’s screen takeover. Opened in append mode with 0600 perms.
--no-compactContext management → Compaction — disable automatic compaction (/compact slash still works)
--no-checkpointDeprecated alias for --checkpoint=off (which is config-backed — see checkpoint above). Removes /done and the heuristic as well as mark_task_done, broader than its original description implied
--agentic-toolsContext management → Agentic tool wrappers — register the agentic_* tool family
--agentic-small-model=IDContext management → Agentic tool wrappers — route agentic subtasks to a cheaper model

The CORE_AGENT_TUI=internal environment variable picks the legacy internal/tui code path in place of the v2 default (core-tui). One-release escape hatch for operators who hit a regression; scheduled for removal in v2.1.