Purpose. This is the reference for every configuration surface Agentao reads at startup or runtime: file paths, schema, defaults, and precedence. It deliberately does not explain why a feature exists or how to use it — each section links back to the corresponding feature doc for that.
If you find yourself writing a paragraph of motivation here, move it to the feature doc and leave a one-line link.
中文版本:CONFIGURATION.zh.md。Both docs share the same structure, section numbering, and field tables — when editing one, update the other in the same change.
Quick check. To verify any of the files below without booting an agent, run
agentao config validate(oragentao doctorfor plugin / ACP / optional-dep coverage as well). Both support--json; errors exit 1, warnings exit 0. See developer-guide CLI ch. 12.
User-facing configuration files (the surfaces you may hand-edit):
| # | Surface | Project path | User (global) path | Loader | Feature doc |
|---|---|---|---|---|---|
| 1 | LLM env | .env (cwd) |
shell env | dotenv.load_dotenv → discover_llm_kwargs |
— (see .env.example) |
| 2 | Runtime mode + builtin agents | .agentao/settings.json |
— | embedding/factory.py::_load_settings, plan/controller.py::_load_settings |
developer guide 5.4 |
| 3 | Tool permissions | — (user-only; project file is ignored) | ~/.agentao/permissions.json |
permissions.py::PermissionEngine |
developer guide 5.4 |
| 4 | MCP servers | .agentao/mcp.json (add-only — cannot override user-scope names) |
~/.agentao/mcp.json |
mcp/FileBackedMCPRegistry (see mcp/config.py) |
CLAUDE.md § MCP |
| 5 | ACP subagents | .agentao/acp.json |
— (project-only) | acp_client/config.py |
CLI 8 · /acp / developer guide 3.4 |
| 6 | Skills disable list | .agentao/skills_config.json |
— | skills/manager.py |
developer guide 5.2 |
| 7 | Project instructions | AGENTAO.md (cwd), else AGENTS.md |
— | prompts/helpers.py::load_project_instructions |
developer guide 5.6 |
| 8 | Memory store | .agentao/memory.db |
~/.agentao/memory.db |
memory/manager.py::MemoryManager |
developer guide 5.5 |
| 9 | Run spec (agentao run) |
any path passed to --spec (or stdin) |
— | cli/run_models.py::RunSpec, cli/run_template.py::render_spec |
run-spec-parameters.md |
| 10 | Plugin hooks | <plugin>/hooks/hooks.json, or inline/declared via the plugin manifest's hooks |
— (travels with the plugin) | embedding/plugins/manager.py (discovery) → plugins/hooks/_parser.py (parse) |
§11 below; Developer Guide §5.7 |
| 11 | LLM login (ACP sessions) | — | ~/.agentao/llm.json (written by agentao --login) |
embedding/llm_config.py::resolve_session_llm_config |
§2a below; developer guide 3.2 · Provider configuration and login |
Internal state files (auto-managed; documented for awareness, not for editing):
| Surface | Path | Owner | Notes |
|---|---|---|---|
| Background sub-agent task state | .agentao/background_tasks.json |
agents/bg_store.py::BackgroundTaskStore |
Anchored to working_directory; in-memory only when no persistence_dir. Hand-edits will desync running threads. Keeps at most 50 finished records from earlier conversations and runs (the newest by finished_at), pruned at startup and at each launch; this conversation's records and running ones are never pruned. At most max_concurrent of the store's own tasks are pending or running at once (constructor keyword, default 6, None for no cap; 0.5.7) — a launch past it is refused, and another process's records in this file do not count toward it. A record with status: "failed" carries incomplete_reason when the run finished without raising but never answered — its presence is what separates "crashed" (no result) from "stopped short" (result still present and worth reading). Absent on records written before 0.4.15. Since 0.4.24 a record with status: "cancelled" keeps the result and the counters the run produced when it was cancelled while running, and carries neither when it was cancelled before it started; incomplete_reason is None on both — the status already names the cause. Not secret-scanned — see the note below. |
| Replay events | .agentao/replays/*.jsonl |
replay/ |
See developer guide 6.6. |
| Sessions / plans | .agentao/sessions/, .agentao/plan-history/ |
various | Per-session artifacts. Not secret-scanned — see the note below. |
| Tool outputs | .agentao/tool-outputs/ |
runtime/tool_result_formatter.py::_save_and_truncate |
Oversized tool output spilled to disk, referenced from the in-context excerpt. Anchored to working_directory (since 0.5.7; before, the process cwd — which an ACP server or embedded host does not share with its sessions). Files older than 7 days, by mtime, are pruned on a session's first spill. Secret-scanned before write. |
/goal continuation state |
.agentao/goal.json |
cli/goal_state.py::GoalState |
One long-task goal (objective, status, time/turn caps, usage). Survives restarts; corrupt/missing → treated as no goal. See developer guide CLI 13. |
Secret scanning is asymmetric, deliberately.
.agentao/tool-outputs/is passed throughsecurity/secret_scan.py::scan_and_redactbefore it reaches disk — credential-shaped strings become[REDACTED:<kind>]..agentao/sessions/*.json(embedding/sessions.py::save_session) and.agentao/background_tasks.jsonare not scanned, and that is a trade rather than an oversight: both round-trip back intoagent.messageson resume, so redacting them would silently corrupt a resumed conversation the same way redacting the live tool result would corrupt a live one. Redaction is applied where the output is terminal, withheld where it is re-read.The tool result the model reads is likewise never redacted — patterns cannot tell a live credential from a test fixture. When a tool-output write does redact something, the excerpt handed to the model gains a note so it does not copy mangled text back into a real file:
(credential-shaped strings in the saved copy are replaced with [REDACTED:<kind>]; the excerpt below is verbatim).
Precedence rules (applies only to surfaces with both project and user variants):
- Permissions — user-scope file is the only rule source. A project-scope
.agentao/permissions.jsonis ignored with a warning (a checked-in{"tool": "*", "action": "allow"}would defeat the user policy on the first match). After custom user rules, the active mode's preset rules run last (except infull-access/planmodes, where presets run first and cannot be overridden). - MCP — both files are read. Add-only for project scope: a project entry may declare a new server name, but cannot override a user entry with the same name (warning + skip on collision). This prevents a checked-in
mcp.jsonfrom silently redirecting a known server name (e.g.github) to a different transport. - Memory — project DB and user DB are read independently; both are visible in the prompt. Project does not override user.
- All other user-facing surfaces are project-only — no merge.
- Path.
<cwd>/.env. Loaded bydotenv.load_dotenv()at the top ofembedding/factory.py::build_from_environment. Not on the ACP server's session path (0.5.8): there it is read, never loaded into the process environment, and only its LLM variables are used — see §2a. - Loader.
embedding/factory.py::discover_llm_kwargs. - Mechanism. Provider-prefixed:
LLM_PROVIDER(defaultOPENAI) selects which{PROVIDER}_API_KEY/{PROVIDER}_BASE_URL/{PROVIDER}_MODELtriple is read, plus that block's optional{PROVIDER}_API_FORMAT.
| Variable | Required | Default | Notes |
|---|---|---|---|
LLM_PROVIDER |
no | OPENAI |
Selects the prefix for the three vars below. Examples: OPENAI, DEEPSEEK, GEMINI, ANTHROPIC. |
{PROVIDER}_API_KEY |
yes | — | Startup fails if missing. |
{PROVIDER}_BASE_URL |
yes | — | Startup fails with ValueError if missing. |
{PROVIDER}_MODEL |
yes | — | Startup fails with ValueError if missing. |
{PROVIDER}_API_FORMAT |
no | openai-completions |
The wire protocol spoken to that provider block's endpoint: openai-completions (Chat Completions), anthropic-messages (Anthropic's Messages API, over the official anthropic SDK, a core dependency) or openai-responses (OpenAI's Responses API, over the same openai SDK as Chat Completions; 0.5.3). It rides the provider prefix because it is a property of the endpoint, and it is never inferred from the base URL, the provider name or the model name: LLM_PROVIDER=ANTHROPIC pointing at an OpenAI-compatible gateway is a working configuration and stays on Chat Completions until you say otherwise. An unknown or not-yet-implemented value (gemini-api) raises at startup and lists the valid ones; empty / unset means the default. A provider switch carries it: /provider reads the target block's value and changes the wire along with the credentials — clearing what a model or endpoint change clears (thinking artifacts in history, the token anchor, the observed context limit, the capability latches, the explicit prompt-cache breakpoints) and keeping LLM_EXTRA_BODY, whose keys it names because they were written for the other protocol; a bad value on the target block leaves the session where it was. Sub-agents are built on the parent's current wire. On anthropic-messages: {PROVIDER}_BASE_URL is the API root (https://api.anthropic.com — a trailing /v1 carried over from the compat endpoint is dropped, since the SDK appends /v1/messages); LLM_TEMPERATURE and /temperature have no effect — anthropic 1.6.0 has no temperature parameter on messages.create at all, so a gateway that does take one gets it through LLM_EXTRA_BODY; max_tokens is required by the protocol, so a call that names none sends LLM_MAX_TOKENS (default 65536), and the model's cap is taken from the provider's Models API (GET /v1/models/{id}, asked once per model before the first request) so a larger value is clamped rather than rejected; on an endpoint without that route (compatible gateways mostly answer 404, which changes nothing) a model that answers max_tokens: N > M has M adopted for the rest of the session at the cost of that one rejected request — set LLM_MAX_TOKENS to the model's cap to avoid it. The same lookup's max_input_tokens can narrow the context window (AGENTAO_CONTEXT_TOKENS / max_context_tokens= stays the ceiling; see host-api, Two context windows); extended thinking is turned on through LLM_EXTRA_BODY — {"thinking": {"type": "adaptive"}, "output_config": {"effort": "high"}} on current models (claude-sonnet-5 rejects the older form with a 400), {"thinking": {"type": "enabled", "budget_tokens": 8000}} on older ones; a current model may return its thinking text empty beside the signature, so there is nothing for the CLI to show — and /thinking <level> writes output_config.effort here rather than reasoning_effort, which this protocol rejects (levels `low |
LLM_TEMPERATURE |
no | unset | Range 0.0–2.0. Unset means not sent: the request carries no temperature and the provider's own default applies (0.5.7; before, 0.2 was sent). Malformed value raises at startup (float()). Not sent on the anthropic-messages wire — see {PROVIDER}_API_FORMAT. |
LLM_MAX_TOKENS |
no | — | Provider-default if unset. Malformed value raises at startup (int()). |
LLM_EXTRA_BODY |
no | — | JSON object forwarded verbatim to the LLM .create() call as the SDK's extra_body request option — the escape hatch for params the closed request build does not expose (reasoning_effort, top_p, seed, response_format, and provider-specific fields). Example: LLM_EXTRA_BODY='{"reasoning_effort":"high"}'. Unlike the two above, a malformed or valid-but-non-object value is warned and skipped (not a startup error). The host configures its own endpoint; the SDK/provider validates the values. Logged with credential-like keys redacted. See docs/design/host-llm-extra-params.md. |
LLM_PROMPT_CACHE |
no | off | Explicit prompt-cache breakpoints over the ordinary Chat Completions wire. anthropic, or off / empty / unset for none. When on, each agent-turn request carries at most 3 cache_control: {"type":"ephemeral"} markers — on the system message, the last tool definition, and the end of stable history — leaving the 4th breakpoint slot free for the endpoint's automatic caching. Opt-in by design: agentao verified that the OpenAI SDK forwards the key unchanged (openai 2.24.0), but not that any given gateway honours it, so this is never inferred from your base URL or model name — you name it for an endpoint you have verified. An unknown value raises at startup (unlike LLM_EXTRA_BODY): a typo here would silently disable the one thing it was set to enable. Markers never enter history, session files or the replay record, and are dropped automatically when /provider changes the base URL (a new deployment the assertion no longer covers). Markers a host placed on its own messages count against the three and are never overwritten, so their ttl survives — and they constrain where agentao may add its own: Anthropic requires longer-lived entries to precede shorter-lived ones, so a site that would put a configured 1h behind a caller's 5m, or a configured 5m ahead of a caller's 1h, is skipped rather than re-timed. A ttl agentao does not recognise suppresses its own markers entirely rather than guessing a rank. Three things to check on the endpoint you are verifying, because agentao cannot: (1) it forwards cache_control rather than 400ing on it; (2) it accepts a role: "tool" message whose content is an array of text parts — legal OpenAI, but the conversation breakpoint lands on a tool result whenever the previous iteration ran tools, and some gateways accept only a plain string there; (3) its usage.prompt_tokens counts cached tokens in rather than net of cache reads. (3) matters because the Tier-1 token anchor reads that field as the cost of the persistent prefix: a gateway reporting Anthropic's cache-exclusive input_tokens under that name makes a cached 40k history read as a few hundred tokens, and compaction stops firing until the provider's own context-length error drops the turn into the overflow ladder. An anchor that collapses all the way to zero is dropped rather than trusted, but a merely implausible one is not detectable from a single response — this is a known limitation of stage 0b, not a solved problem. See agentao/llm/_cache_control.py and docs/design/llm-api-adapters.md §2.3. |
LLM_PROMPT_CACHE_TTL |
no | 5m |
Retention hint for the markers above: 5m (the provider default, same as unset) or 1h. Ignored when LLM_PROMPT_CACHE is off — including the validation, so an unknown value raises at startup only when the format is on, and is silently unread otherwise. |
BOCHA_API_KEY |
no | — | web_search runs an ordered fallback chain that retreats to the next backend on error (not on an empty result): jina (if JINA_API_KEY) → bocha (if BOCHA_API_KEY) → duckduckgo (keyless, always the last resort). Each fallback is surfaced in the result and logged. Pinning WebSearchTool(backend="bocha"|"jina"|"duckduckgo") selects exactly that one backend with no auto-fallback. Set at least one key: with none, the chain is duckduckgo alone, and html.duckduckgo.com frequently answers a bot-challenge page (HTTP 202, no results container) rather than results — reported as a backend error, not as an empty result. |
AGENTAO_WEB_FETCH_FALLBACK |
no | none |
JS-rendering fallback for web_fetch. Allowed: none / jina / playwright. Default none — the tool never silently proxies user-supplied URLs through a third party. jina sends the URL to https://r.jina.ai (disclosed in tool description + result Fallback: line). playwright renders locally in a headless Chromium — nothing leaves the machine — and requires pip install 'agentao[playwright]' plus playwright install chromium (the browser binary is a separate download; missing it surfaces at render time, not import time). Read once at WebFetchTool construction; invalid values warn and degrade to none. Renamed in 0.4.18 — the retired value crawl4ai is still accepted, maps to playwright, and logs a warning; see the migration note in CHANGELOG.md. |
AGENTAO_WEB_FETCH_ALLOW_CIDRS |
no | — | Opt-in SSRF allowlist for web_fetch: comma/space-separated CIDRs (or bare IPs) that the URL policy permits even though they are not globally routable. The escape hatch for hosts behind a fake-IP proxy (Clash/V2Ray map every domain to a reserved range, typically 198.18.0.0/15, which the guard otherwise blocks) or a trusted internal service. Applied to the initial URL and every redirect hop; the allowlist is scoped — listing 198.18.0.0/15 does not also permit 169.254.169.254. A cloud metadata endpoint (169.254.169.254, 169.254.170.2, Azure's public 168.63.129.16, 100.100.100.200, 192.0.0.192, fd00:ec2::254, …), in any IPv4 or IPv6 encoding, opens only when the list names its exact address; a range that contains it, even 169.254.0.0/16, does not. Relaxes a security control — logged once at startup, surfaced in the tool description; keep it narrow, never 0.0.0.0/0. Default empty = fully strict. (Cleaner fix: switch the proxy DNS from fake-ip to redir-host/real-IP.) See agentao/security/url_policy.py. |
JINA_API_KEY |
no | — | Jina key, sent as Authorization: Bearer <key>. The two Jina endpoints differ: web_search (the jina backend via s.jina.ai) requires it — measured 2026-07-22 the endpoint answers 401 AuthenticationRequiredError without one, so a keyless jina pin fails every query and a keyless auto chain simply omits jina. web_fetch with AGENTAO_WEB_FETCH_FALLBACK=jina (r.jina.ai) still works keyless; there the key only lifts the rate limit. |
AGENTAO_SCRUB_CHILD_ENV |
no | on | Whether shell and MCP child processes inherit agentao's own provider credentials. Default (any value other than 0/false/no/off) drops HARNESS_ENV_KEYS — OPENAI_API_KEY, ANTHROPIC_API_KEY, LLM_EXTRA_BODY, … — from the child environment, so a prompt-injected run_shell_command("env") finds nothing worth stealing. The agent is a distinct principal from the user, and nothing the LLM decides to run needs the key that pays for the LLM. Set to 0 to restore full inheritance — required if you run agentao run, or any script that calls the provider, from inside the agent's own shell. Only agentao's keys are dropped; the user's other secrets (AWS, GitHub, DATABASE_URL) are untouched — scrubbing those is the host's call, and a host wanting a tighter environment supplies it from its own ShellExecutor (the request carries a complete launch.env, so a host executor decides what it actually passes to Popen). Defense in depth, not a seal: cat .env still works. See agentao/capabilities/process.py::build_child_env. |
Canonical example:
.env.examplein the repo root.
agentao --login — run by hand or by an ACP client's Terminal Auth — writes one provider block to ~/.agentao/llm.json with mode 0600:
{"provider": "DEEPSEEK", "api_key": "sk-…", "base_url": "https://api.deepseek.com/v1", "model": "deepseek-chat", "api_format": "openai-completions"}api_format takes the same values as {PROVIDER}_API_FORMAT. The login always writes it (default anthropic-messages for ANTHROPIC, openai-completions otherwise, or the replaced file's own value for the same provider); a hand-written file may omit it, which means the default wire. Unknown keys are ignored; a non-string field or invalid JSON is a configuration error.
The file is read only by the ACP server's default session path (agentao --acp). The interactive CLI, agentao run and build_from_environment() do not read it. Each session/new / session/load resolves the session's LLM settings afresh (embedding/llm_config.py::resolve_session_llm_config):
- Layers, highest first: the server's launch environment (snapshotted at startup) → the session's
<cwd>/.env(no search abovecwd;${VAR}references expand against the launch snapshot, not the live environment) →~/.agentao/llm.json. An empty or whitespace-only value is unset in every layer. - Provider first: the highest layer that names one (
LLM_PROVIDER, or the file'sprovider), elseOPENAI. Whether a layer holds an API key plays no part in the choice. - Fields second:
{PROVIDER}_MODELand the sharedLLM_*variables, layer by layer, for the chosen provider only. The file contributes only when itsprovidermatches. - Key, base URL and wire format together:
{PROVIDER}_API_KEY,{PROVIDER}_BASE_URLand{PROVIDER}_API_FORMATcome from the highest layer that sets the key. If that layer has no base URL, the URL is missing (auth_required), not borrowed from a lower layer — so anOPENAI_API_KEYexported for another tool is never sent to the gateway URL you logged in with. If it has no format, the default wire applies — a login'santhropic-messagesnever reaches a project's OpenAI-compatible gateway. Put a key, its URL and its format in the same place. - Snapshot: the result is fixed on the session. A
session/set_config_optionprovider switch resolves against it, and a later login does not change it.
Nothing is written into os.environ. Only LLM variables are taken from the project .env on this path — MCP ${VAR} expansion, JINA_API_KEY / BOCHA_API_KEY, GITHUB_TOKEN, AGENTAO_WEB_FETCH_* and the like must come from the launch environment (the ACP client's env block). Missing api_key, base_url or model after all three layers is auth_required (-32000); an unusable source is -32603 naming the file or variable, never its value. A host-injected agent_factory or provider_resolver bypasses all of this and keeps the environment behaviour above.
- Path.
<cwd>/.agentao/settings.json(project-only, no user variant). - Loaders.
embedding/factory.py::_load_settings— readsagents.enable_builtin/enable_builtin_agentsto default the constructor'senable_builtin_agentsflag.plan/controller.py::_load_settings— readsmodewhen restoring the active permission mode after a plan-mode session ends.cli/app.py::_read_auto_wake— readsbackground_agents.auto_wakeonce at interactive CLI startup.
- Failure mode. Missing file → treated as
{}, silently. Unreadable, not valid UTF-8, malformed JSON, a top-level value that is not an object, or not a regular file (a directory or a named pipe) → a warning naming the path, then treated as{}(still no startup error). An integer literal longer than Python's conversion limit (4300 digits by default) counts as malformed JSON, here and in every other JSON file agentao reads. Read asutf-8-sig, so a BOM'd file loads rather than being discarded./replay on//replay offwrite this file back, so they refuse instead: when it cannot be read as a JSON object, or holds a number JSON cannot represent outsidereplay.max_instances, they print an error and leave the file unchanged, since writing over it would drop every other key./modewrites the file too and refuses the same way when it cannot be read as a JSON object; the mode still applies for the session. A non-finitereplay.max_instances(1e309reads as infinity) is written back as the count in effect, 20. The warning goes to theagentaologger: it reaches the terminal only while no handler is attached yet (Python'slastResort), which in practice covers thesettings.jsonreads but notmcp.json/skills_config.json, both of which are read after the LLM client attaches its file handler — those land inagentao.log.agentao doctorsurfaces all of them. - Important. The factory does not apply
modeto the engine on startup; thePermissionEnginealways initializes atworkspace-write. Themodefield is the persisted last-known mode used for restoration paths and CLI inspection — runtime mode changes go through CLI commands orPermissionEngine.set_mode().
{
"mode": "workspace-write",
"agents": {
"enable_builtin": false
},
"goal": {
"enabled": true,
"default_max_turns": 25,
"default_time_budget": "120m"
},
"background_agents": {
"auto_wake": true
}
}| Key | Type | Default | Allowed values | Notes |
|---|---|---|---|---|
mode |
string | "workspace-write" (when key absent) |
"read-only", "workspace-write", "full-access" |
"plan" is internal — set by /plan flow, never written by users. "full-access" disables all per-tool prompting; use deliberately. |
agents.enable_builtin |
bool | false |
— | Enables the built-in sub-agent set. Legacy top-level alias enable_builtin_agents (bool) is still honored. |
goal.enabled |
bool | true (when key absent) |
— | Master switch for the /goal long-task continuation. Set false to disable the command. |
goal.default_max_turns |
int | 25 |
positive int, or 0 for no turn cap |
Turn cap applied when /goal is set without --turns (and not --unbounded). One turn = one outer continuation chat() — not max_iterations (which bounds the inner tool loop). Primary runaway guard. |
goal.default_time_budget |
string | "120m" |
duration 90s / 30m / 2h / 1h30m; empty/absent → no time cap default |
Active wall-clock cap applied when /goal is set without --for. Sized above the turn cap so it guards only wall-clock pathology and does not shadow the turn cap (see docs/design/codex-goal-mechanism-review.md §11.1 C). |
background_agents.auto_wake |
bool | true (when key absent) |
true, false |
Interactive CLI only. When a background sub-agent's update is queued while the CLI waits at an empty prompt, start one turn automatically (⟳ background agent finished — continuing) so the update is read without typing continue. It does not fire while you are typing, with /image attachments staged, or in plan mode, and it pauses after 3 wakes in a row until you submit a line. false turns it off; any other value is reported and read as true. agentao run, ACP and embedded hosts never wake on their own (see host-api, Continuing after a background sub-agent). |
/goalstate itself is not stored insettings.json— it lives in.agentao/goal.json(see §1 file table). Per-goal caps set with/goal <obj> --for/--turnsor/goal budgetoverride these defaults;--unboundedopts out of them entirely.
See developer guide 5.4 for what each mode actually permits.
Tool-set selection is construction-only — not a
settings.jsonfield (v1). Which tools the model sees is chosen atAgentao(...)construction, not via any config file:
enabled_tools={...}— additive allowlist: keep only the named built-in / agent-path tools (extra_tools, MCP, plan-only always kept).None= disabled; the empty set still enables it. Mutually exclusive withdisable_tools.disable_tools={...}— subtractive: skip named built-ins.extra_tools=[...]— inject pre-builtTool/AsyncToolBaseinstances.These are pure data (names) or instances, not JSON surfaces — wiring them through
settings.jsonis deliberately deferred (demand-gated; seehost-tool-allowlist.md§8). Schema + semantics:docs/reference/host-api.md,host-tool-allowlist.md,host-tool-injection.md.Sub-agents get the tools their parent has when they are launched, narrowed to the agent definition's
tools:list (all of them when it has none). Whatever these three options removed from the parent is therefore absent from its sub-agents too. Of the rest, a sub-agent keeps only:
- built-in tools, as its own instances bound to the parent's
filesystemandshell;- MCP tools, which run over the parent's connections, so a sub-agent reads no
mcp.jsonand launches no server of its own;complete_task, which it always gets.It gets a host tool (
extra_tools=,add_tool, a bareregister) only when the tool object declarescopies_to_subagents, and then as one shallow copy made at spawn. An undeclared host tool is absent and so is the name it occupied — a host tool that replaced a built-in does not leave the built-in under that name — as is one whose copy — or whose declaration — raises, which logs a warning naming the exception. Agent tools and plan tools are never included. A definition whosetools:list names something it does not get logs a warning; the list still applies to a declared host tool. Seeagents/tools/_wrapper.py::_narrow_tools.A sub-agent decides tool calls with a snapshot of the parent's permission engine, taken when it is launched: the same rules (including a host's
rules=and anagentao runspec's) and the same mode. A background sub-agent has nobody to ask, so it refuses every call that would need confirmation; what the rules or mode allow outright still runs.
- Paths.
~/.agentao/permissions.json(user-level) — the only file-based rule source.<cwd>/.agentao/permissions.json(project-level) — ignored with a warning. See "Why no project scope?" below.
- Loader.
embedding/permission_loader.py::load_permission_rules. The engine does no file I/O when givenrules=(asAgentao(permission_mode=)does); built withrules=Noneit calls this loader. - Failure mode — this file fails closed (changed in 0.4.20; it previously degraded to an empty rule list with no startup error). Missing file → empty rule list, silently. Anything else — unreadable, not a regular file (a directory or a named pipe), not valid UTF-8, malformed JSON, a top level that is not an object, an unknown top-level key (
rulesis the only legal one, so{"rule": [...]}is rejected rather than silently loading zero rules), or a rule that fails validation — raisesPermissionConfigErrornaming the path, and session construction aborts. The asymmetry with every other config file is deliberate: a droppeddenyon a shell or web tool degrades to ask, and a droppeddenyon anmcp_*tool degrades to nothing at all (the engine returns no decision, the runtime falls through to the tool's ownrequires_confirmation, and atrust: trueserver's tool then runs unprompted).agentao doctorreports the same failures without aborting. Read asutf-8-sig, so a BOM'd file loads. - Provenance. A file that loads successfully contributes a
loaded_sourceslabel (user:<path>) returned fromPermissionEngine.active_permissions()andAgentao.active_permissions()— seedocs/reference/host-api.md. - Public getter.
PermissionEngine.active_permissions()returns a cached, JSON-safeActivePermissionssnapshot (mode,rules,loaded_sources). Hosts that layer policy on top can calladd_loaded_source("injected:<name>")so the snapshot reflects their provenance. The cache is invalidated onset_mode()andadd_loaded_source(). - Evaluation order.
- Modes
read-only/workspace-write:[user rules] → [active mode preset rules](first match wins). Inread-onlythis order applies only to the tools read-only mode admits: any other tool is denied before a rule is consulted, so no rule can allow it. - Modes
full-access/plan:[active mode preset rules] → [user rules]— presets cannot be overridden. - No match →
decide()returnsNone; the runner falls back to the tool's ownrequires_confirmationattribute.
- Modes
Why no project scope? Permissions are a user/host concern, not a cwd concern — same model OS permissions and IDE workspace-trust use. A checked-in project file with
{"tool": "*", "action": "allow"}would defeat the entire user policy because the engine returns on the first matching rule. If you need project-aware policy, inject it from the host viaadd_loaded_source("injected:<name>")plus your own rule layer.
{
"rules": [
{"tool": "run_shell_command", "args": {"command": "^git "}, "action": "allow"},
{"tool": "write_file", "action": "ask"},
{"tool": "run_shell_command", "args": {"command": "rm\\s+-rf"}, "action": "deny"},
{
"tool": "web_fetch",
"domain": {"allowlist": [".example.com"], "url_arg": "url"},
"action": "allow"
}
]
}| Field | Type | Required | Notes |
|---|---|---|---|
tool |
string | yes | Tool name; matched as a regex via re.fullmatch (use "*" for wildcard). |
args |
object | no | Map of <arg_name> → regex; all entries must re.search-match for the rule to fire. Bad regex falls back to literal equality. For file_path on read_file / write_file / replace, a deny or ask pattern is tried against the raw argument, its resolved absolute path, and (inside the project) its resolved project-relative POSIX path, and matches if any of them does — so ^secrets/ covers ./secrets/k, sub/../secrets/k, the absolute spelling and a symlinked alias. An allow pattern sees only the raw argument: matched against more spellings it would grant more, and a negative lookahead such as ^(?!secrets/) matches the absolute spelling of the file it excludes. |
domain |
object | no | URL-tool only (web_fetch). Keys: url_arg (default "url"), allowlist, blocklist. Patterns starting with . do suffix matching (e.g. .github.com matches github.com and api.github.com); otherwise exact match. A rule with domain matches only when the hostname hits one of its lists. |
action |
string | yes | "allow" | "deny" | "ask". Case-insensitive. Unknown values are rejected as of 0.4.20 — they were previously treated as "ask", which is what let {"action": "alow"} sit in a config doing nothing while /permissions printed it back as [? ALOW]. |
The key set is closed, and tool / action are required. tool, args, domain, action — and under domain, url_arg / allowlist / blocklist. Any other key is a validation error rather than a silently ignored field (0.4.20). Presence is checked too, because the engine's fallback for an absent key rewrites the rule instead of refusing it: rule.get("tool", "*") makes {"action": "allow"} an allow-everything rule, and rule.get("action", "ask") quietly re-labels a rule its author meant as a deny. Write {"tool": "*"} for a deliberate wildcard. The engine reads only these four, so before validation existed a one-word typo dropped a rule's condition and widened it: {"tool": "run_shell_command", "pattern": "^git ", "action": "allow"} — pattern should be args — allowed every shell command, and rendered as an ordinary [✓ ALLOW]. Types are checked too; the same validator runs for file rules, PermissionEngine(rules=...), and a run spec's permissions: block.
Built-in presets live in permissions.py::_PRESET_RULES and run after custom rules (or before, in full-access / plan):
workspace-write— auto-allowswrite_file/replace, except a target under a.gitor.agentaodirectory (or a.gitfile) at any depth, which asks:.git/configcan make the nextgit statusrun a command (core.fsmonitor), and.agentao/holds MCP servers, hooks and plugins. Credential files ask too, at any depth:.envand.env.*(not.env.example,.sample,.templateor.dist),*.pemand*.key. Allowlists read-only shell commands (ls,cat,grep,head,tail,wc,diff,du,df,ps,pwd,which,echo,git status|log|diff|show|…) only when the command has no shell operators, quotes, backslashes, parentheses or glob/brace characters, and — for git — no--output,--ext-diffor--textconv(or an abbreviation of one); anything else asks. Drivers already present in git configuration can still run from those git commands; deniesrm -rf/sudo/mkfs/dd if=; allowlists trusted docs domains (.github.com,.docs.python.org,.wikipedia.org,.pypi.org,.readthedocs.io,r.jina.ai); blocklists SSRF targets (localhost,127.0.0.1,0.0.0.0,169.254.169.254,.internal,.local,::1); rest → ask.read-only— empty preset;ToolRunnershort-circuits ontool.is_read_only, denying every tool that is not read-only. It does so whether the mode came from the runner's flag (CLI,agentao run) or only from the engine (session/set_modeover ACP, an embedded host'sset_mode). Read-only tools are the readers plusactivate_skillandtodo_write, which change only session state;save_memorywrites SQLite and is denied.full-access— single rule{"tool": "*", "action": "allow"}.plan— denies all writes / memory mutations; allows the read-only shell allowlist; web rules identical toworkspace-write.
Full rule taxonomy, examples, and runtime semantics → developer guide 5.4.
permissions.json also accepts a top-level shell object, read only from the user-level file
(<home>/.agentao/permissions.json) and never from the workspace copy — a block checked into a
repository would let the repository choose the interpreter the agent runs.
| Key | Type | Notes |
|---|---|---|
dialect |
"posix" / "cmd" / "powershell" |
The syntax the interpreter reads. This is what the command floor scans with and what the prompt's shell guidelines speak. On its own it is enough: {"dialect": "powershell"} is the ordinary way to turn PowerShell on. |
path |
string | Absolute path to a specific interpreter. Requires dialect — a renamed launcher says nothing about the syntax it reads, so a path alone is an error rather than a guess. dialect without path is fine. |
The key set is closed: anything else is a named error at startup, not a silently ignored field.
What each configuration does
| Configuration | Windows | macOS / Linux |
|---|---|---|
absent, or {"dialect": "cmd"} |
%COMSPEC% /c <command> — the default, unchanged |
error: cmd is Windows-only |
{"dialect": "powershell"} |
finds pwsh.exe, else powershell.exe; an error if neither is installed, never a fall back to cmd |
error: powershell is Windows-only |
{"dialect": "posix"} |
error — give an explicit path; agentao does not go looking for Git Bash, WSL or MSYS, which differ in path translation and in what they can reach |
/bin/bash -c <command>, or /bin/sh where there is no bash |
{"path": …, "dialect": …} |
exactly that interpreter, read as that syntax | same |
A configuration this platform cannot run is refused rather than quietly replaced, and every shell call then reports that refusal. Falling back would be the interesting bug: cmd reading a body written for PowerShell does not fail, it means something else.
Changes take effect on a new session — rebuild or restart.
How PowerShell is launched. <interpreter> -NoLogo -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand <base64>, started directly with no shell in between. The command is wrapped in a
fixed prelude and a fixed exit line, encoded as UTF-16LE and base64'd, which is what removes every
quoting question — backslashes, percent signs, quotes and newlines all survive untouched. Three
consequences worth knowing:
-NoProfilemeans your PowerShell profile is not loaded, so functions and aliases defined there do not exist for the agent.- The exit code follows the last statement. A native command's own non-zero code is preserved; a
cmdlet that only wrote an error gives 1; an explicit
exit Nand a terminating error are PowerShell's own. A failure earlier in the body followed by a success is a success — the body is not rewritten into fail-fast. - Length. Base64 of UTF-16LE costs about 8 characters of command line per 3 characters of command, and Windows caps a command line at 32,767 units, so a command over roughly 12,000 characters is refused with a message saying so. It is never truncated and never spilled to a temporary file.
Encoding. The prelude sets $OutputEncoding and [Console]::OutputEncoding to UTF-8, so
non-ASCII output and non-ASCII piped into a native program both survive. A native program that
writes some other encoding is still not transcoded — that is the program's own choice and nothing
here can know it.
Two measured quirks of Windows PowerShell 5.1, both of which are the interpreter's own and neither of which the prelude can reach:
- What it pipes into a native command begins with a UTF-8 byte order mark. A program reading that
stdin sees
\ufeffbefore the first character. PowerShell 7 does not do this, and nothing in the prelude changes it — five variants were measured on a Windows runner, including no prelude at all, and 5.1 emitted the mark in every one. Strip a leading\ufeffif the program you are piping into does not. - Cmdlets that write files default to the system ANSI code page.
Set-Content -Value '中文'under 5.1 writes??, silently; PowerShell 7 defaults to UTF-8 and writes the characters. The prelude sets the two stream encodings and deliberately stops there — changing a cmdlet's default would change what your commands mean, and 5.1'sutf8is UTF-8 with a BOM. Name the encoding when it matters:Set-Content -Encoding utf8, or[System.IO.File]::WriteAllText(...)with an encoder you chose.
Background commands (is_background: true) start the interpreter with its own console window
that is never displayed, and a new process group. Output goes nowhere: the model gets a process id,
not a transcript.
Discovery checks known install locations (the PowerShell 7 directories under Program Files and
%LOCALAPPDATA%, the Store alias directory, and System32/SysWOW64 for Windows PowerShell 5.1)
and then the absolute directories on PATH. It never searches the working directory, and it never
picks Git Bash. A newly installed interpreter is not visible to an agentao that was already running:
restart it, or give an absolute path.
The command floor. The dialect-independent floor — the one that refuses rm -rf /, mkfs, dd
to a device and the rest — runs on every dialect, always. On PowerShell a table of Windows-specific
irrecoverable classes runs in addition to it: formatting a volume, wiping a disk, deleting shadow
copies, disabling BitLocker, and a recursive Remove-Item on a drive root, recognised through
PowerShell's built-in aliases (rm, ri, del, rd, …) and abbreviated parameters (-r for
-Recurse). Windows' default cmd path deliberately keeps running only the dialect-independent
floor, so format C: typed into cmd is not stopped there; that is the status quo being preserved,
not a claim that the command is safe, and the permission rules still apply.
Design: docs/design/powershell-support-lightweight.md (Chinese: …-lightweight.zh.md).
- Paths (load order).
~/.agentao/mcp.json(user-level) — authoritative for any name it declares.<cwd>/.agentao/mcp.json(project-level) — add-only: may declare new server names, but cannot override a user-scope name with the same key. Collisions log a warning and skip the project entry.
- Loader.
mcp/config.py. Env vars inenv,headers,argsandoauth.client_secretare expanded ($VARand${VAR}). An unset or empty variable inheadersoroauth.client_secretkeeps that server from connecting, with an error naming the variable:"Authorization": "Bearer ${TOKEN}"withTOKENunset would otherwise sendBearer, and theAuthorizationheader turns OAuth off. An unset variable inenvorargsexpands to an empty string and logs a warning naming it. - Tool names. Each tool is registered as
mcp_<server>_<tool>, with characters outside[A-Za-z0-9_]replaced by_. A name longer than 64 characters (the Chat Completions limit), or one made from a server or tool name with non-ASCII characters, is cut and given an 8-character hash of the original server and tool names, the same on every connect. Two tools that still map to one name (my-srvandmy_srv) are not both registered: the first one listed keeps the name, and the other is left out with an error inagentao.log. - Failure mode. Missing file → silent. Unreadable, not a regular file (a directory or a named pipe), not valid UTF-8, or malformed JSON → a warning naming the path, then the default (no servers from that file). Read as
utf-8-sig, so a BOM'd file loads./mcp addand/mcp removewrite the project file back, so they refuse instead: when it cannot be read as a JSON object, itsmcpServersis not an object, or it holds a number JSON cannot represent or text UTF-8 cannot encode (a lone surrogate such as"\ud800"), they print an error and leave the file unchanged, since writing over it would delete every server in it.save_mcp_configraisesMcpConfigWriteErrorin the same cases. The warning goes to theagentaologger: it reaches the terminal only while no handler is attached yet (Python'slastResort), which in practice covers thesettings.jsonreads but notmcp.json/skills_config.json, both of which are read after the LLM client attaches its file handler — those land inagentao.log.agentao doctorsurfaces all of them.
{
"mcpServers": {
"<name>": {
"command": "...",
"args": ["..."],
"env": { "TOKEN": "$MY_TOKEN" },
"trust": false
},
"<remote-name>": {
"url": "https://.../mcp",
"headers": { "Authorization": "Bearer $API_KEY" },
"timeout": 30
}
}
}| Transport | type |
Required keys | Optional |
|---|---|---|---|
| stdio subprocess | "stdio" (or infer from command) |
command, args |
env, trust, cwd, timeout |
| Streamable HTTP | "http" (or infer from url) |
url |
headers, timeout, trust |
| SSE (legacy) | "sse" |
url |
headers, timeout, trust |
Every transport also takes resources (default true). "resources": false hides that server from the generic resource surfaces — list_mcp_resources, list_mcp_resource_templates, read_mcp_resource, /mcp resources, and the read_mcp_resource hint in a tool result's resource link — and naming it explicitly is refused (resources are disabled for server '<name>') before any request is sent. Any value other than true or absent counts as disabled. The three tools register only when at least one connected server declares the MCP resources capability; they are read-only (allowed in read-only and plan mode) and never ask for confirmation, whatever the server's trust. A binary resource is saved to <working_directory>/.agentao/tool-outputs/mcp-resource_* (mode 0600, pruned after 7 days with the other saved outputs); any blob over 10 MiB is reported, never decoded. An https:// resource is always read through its server, never fetched directly. See docs/design/mcp-resources.md.
Every transport also takes skills (default false) — the opt-in for the MCP Skills extension (io.modelcontextprotocol/skills). Only true turns it on (agentao doctor flags a non-boolean). Such a server is connected discover-first (server/discover, falling back to initialize), and its skills are listed once at connect, after its tools; a listing failure turns off that server's skills and keeps its tools. The extension needs mcp 2.x and a server speaking protocol 2026-07-28 or later that declares both the extension and resources; otherwise /mcp list says why, once. Each skill joins the catalogue as mcp:<server>:<SKILL.md URI>, labelled with its server. Activating one asks the user once per session (in every mode, under an allow rule too; a headless run refuses), then fetches its SKILL.md and checks size, SHA-256 digest and frontmatter against the server's entry. Its other files are read with read_skill_file, which accepts only files in the skill's manifest. Once an MCP skill is loaded, run_shell_command, spawning a shell-capable sub-agent, and a read_mcp_resource on any other server ask — until /clear. A deny rule still denies. A skill whose SKILL.md is over 100,000 bytes is listed as unavailable (an active skill's SKILL.md is sent with every request; put long material in a supporting file, which read_skill_file reads in pages). Skill content is never saved to .agentao/tool-outputs/, but replay files and agentao.log keep tool results verbatim, MCP skill content included, and a later read_file of them is not gated. A session restore does not re-activate an MCP skill and withholds MCP skill content from the restored transcript. Not supported: "dynamic" skills (listed as unavailable), persisted approvals, a disk cache, refreshing the list without a restart. An ACP-supplied server never has skills. See docs/design/mcp-skills.md.
A URL server (http or sse) uses OAuth by default; no key turns it on. It does not apply to a stdio server, to "oauth": false, or to a server whose headers already set Authorization (any casing). A 401 with a Bearer challenge marks the server needs login; agentao never opens a browser by itself. The login is /mcp login <name> [--no-browser] in the CLI, or agentao mcp login <name> [--no-browser] from a shell; agentao run and ACP sessions never start one, and pick up the new credential on the server's next connect or call. The optional oauth object takes client_id, client_secret (needs client_id; $VAR is expanded), callback_port (integer 1–65535), redirect_host (localhost or a loopback address) and profile (a non-empty string with no leading or trailing whitespace, compared exactly); any other key, or a value that is neither false nor an object, is a config error. There is no scopes key: a login asks for the scope the server's 401 names. Without client_id, agentao registers itself dynamically as public client agentao. Credentials are stored in ~/.agentao/mcp-oauth/, one JSON file per canonical server URL and oauth.profile (file 0600, directory 0700, written atomically), not in an OS keyring. Two names with the same URL and no profile share one credential; give each its own profile to log in to the same URL as two accounts. An entry with a profile never falls back to the URL's no-profile credential, /mcp logout deletes only its own profile's, and a credential stored before profiles existed is still the no-profile one. Tokens refresh automatically 60 s before expiry and once after a 401; a rejected refresh returns the server to needs login, while a network failure is an ordinary error. A server that needed login from startup loads its tools only after a restart. An ACP-supplied server always has "oauth": false. Limitations: on mcp 1.x the SDK does not check the authorization server's iss (RFC 9207), and on mcp 1.26 each login registers a new client; a 403 insufficient_scope is reported but not resolved by logging in again; the login reads internal SDK fields, so an SDK release that moves them stops the login with a message and leaves logged-in servers working. See docs/design/mcp-oauth.md.
envand the base environment. A stdio server'senventries are applied on top of a scrubbed copy of agentao's environment:mcp/client.pybuilds the child env viabuild_child_env(), which drops agentao's own provider credentials (HARNESS_ENV_KEYS). An MCP server that previously relied on inheritingGEMINI_API_KEY,OPENAI_API_KEY, etc. now gets a 401 — pass the key explicitly inenv({"GEMINI_API_KEY": "$GEMINI_API_KEY"}, applied after the scrub) or setAGENTAO_SCRUB_CHILD_ENV=0(§2) to restore full inheritance. Everything else in the environment is inherited unchanged.
Default
User-Agent(URL transports). Streamable HTTP and SSE requests — including the content-type preflight — sendUser-Agent: agentao-mcp/<version>so agentao is identifiable in server logs and isn't filtered as an unknown client. Override it by setting aUser-Agententry inheaders(any casing wins); stdio servers are unaffected.
Transport selection (mcp/config.py :: resolve_transport). The optional
type field selects the transport: "stdio" / "sse" / "http" (aliases
"streamable-http" / "streamable_http" fold to "http"). When type is
omitted it is inferred: command → stdio, url → Streamable HTTP. So a
bare {"url": ...} means Streamable HTTP — set "type": "sse" for the legacy
SSE transport. An unknown type, or a transport missing its required key,
fails closed (McpTransportConfigError) rather than silently connecting to
the wrong protocol.
Migration (breaking). A bare
urlserver previously meant SSE; it now defaults to the Streamable HTTP transport (the spec's replacement for the now-deprecated HTTP+SSE transport). If your server is a legacy SSE endpoint, add"type": "sse"to its entry.
Redirects (URL transports). With
mcp2.2 or later installed — what a fresh install resolves to, since agentao acceptsmcp>=1.26.0,<3— or 1.30 on the 1.x line, the SDK follows a redirect only when it stays on the endpoint's origin (same scheme, host and port, orhttp→httpson the same host). Any other redirect, including one to another port, fails the connect withRedirect to <url> not followed; use that URL as the endpoint…: put that URL inurl. A redirect fromhttpsdown tohttpfails withRedirect to <url> not followed: it would downgrade this HTTPS endpoint…instead, and names thehttpsURL to try — use that one, not the plain-httplocation. Changingtypedoes not help, because the SSE transport applies the same rule, so agentao leaves its "set"type": "sse"" hint off this error. Earlier SDKs followed the redirect.
timeout accepts two forms (mcp/config.py :: resolve_timeouts):
- int / float (legacy) — seconds for the connect / startup phase (default
60): bounds the URL-transport HTTP-connection open and theinitialize()/list_tools()handshake (all transports). Per-request tool calls stay unbounded (the MCP SDK default). Existing configs keep their behavior. - object
{ "startup": int, "request": int }— both keys optional.startupis the connect/handshake bound above (default60);requestbounds each tool call after init (omit → unbounded). Withoutrequest, a hung tool call never self-terminates over stdio, and over SSE or Streamable HTTP only drops after ~300 s with no data from the server — setrequestto cap it deterministically (arequestabove 300 s also raises that read timeout to match). A call pastrequestfails with an error; the server is not stopped, and work it already started may go on.
"slow-server": { "url": "https://...", "timeout": { "startup": 15, "request": 90 } }Tools are registered as mcp_{server}_{tool}. See CLAUDE.md → "MCP" section for the full lifecycle.
If MCP grows a dedicated user-facing feature doc (
features/mcp.md), update this row's link in §1.
- Path.
<cwd>/.agentao/acp.jsononly. No user-level variant — ACP servers are explicitly project-scoped. - Loader.
acp_client/config.py::load_acp_config(parsed intoAcpServerConfigviaacp_client/models.py::AcpServerConfig.from_dict). - Failure mode. Missing
command/args/env/cwdraisesAcpConfigErrorat config load — startup errors out. So does an unreadable, non-UTF-8, or malformed file, or a path that is not a regular file: this surface fails closed, and every failure arrives asAcpConfigError, never as a raw traceback. Read asutf-8-sig. - Hot-reload. The CLI watches the file's mtime; edits are picked up on the next inbox poll (
cli/acp_inbox.py). - Child environment. An ACP server is a third-party binary spawned from config — the same trust position as an MCP server — so it starts from
capabilities/process.py::build_child_env()and does not inherit agentao's own provider credentials. A server that used to work by inheritingOPENAI_API_KEYnow gets a 401; declare the key in itsenvblock instead, or setAGENTAO_SCRUB_CHILD_ENV=0to restore full inheritance process-wide.
{
"servers": {
"<name>": {
"command": "/abs/or/PATH/binary",
"args": ["..."],
"env": { "TOKEN": "$MY_TOKEN" },
"cwd": ".",
"description": "human-readable",
"capabilities": { "chat": true, "web": true },
"autoStart": true,
"startupTimeoutMs": 10000,
"requestTimeoutMs": 60000,
"maxRecoverableRestarts": 3,
"nonInteractivePolicy": { "mode": "reject_all" }
}
}
}| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
command |
string | yes | — | Absolute path or PATH-resolvable executable. |
args |
string[] | yes | — | Empty list [] is valid. |
env |
object | yes | — | Values support $VAR env-var expansion. Applied after the base-environment scrub — see the note below. |
cwd |
string | yes | — | Resolved to absolute against project_root if relative. |
description |
string | no | "" |
Free-form. |
capabilities |
object | no | {} |
Free-form key/value (e.g. chat, web, role: "worker"). Not enforced by the loader. |
autoStart |
bool | no | true |
If false, server stays cold until first call. |
startupTimeoutMs |
int | no | 10000 |
Handshake budget. |
requestTimeoutMs |
int | no | 60000 |
Per-request budget. |
maxRecoverableRestarts |
int | no | 3 |
Cap for auto-restarts after recoverable subprocess deaths; reset on first successful turn. |
nonInteractivePolicy |
object | no | {"mode": "reject_all"} |
Object form only — bare-string form is rejected with a migration error. Allowed mode: "reject_all", "accept_all". |
Full ACP semantics → CLI 8 · /acp and developer guide 3.4.
- Path.
<cwd>/.agentao/skills_config.json(project-only). - Loader.
skills/manager.py. - Failure mode. Missing file → silent. Unreadable, not a regular file (a directory or a named pipe), not valid UTF-8, malformed JSON, a top-level value that is not an object, or a
disabled_skillsvalue that is not an array → a warning naming the path, then the default (no skills disabled); non-string entries inside the array are dropped with a warning and the rest are kept. Read asutf-8-sig, so a BOM'd file loads. The warning goes to theagentaologger: it reaches the terminal only while no handler is attached yet (Python'slastResort), which in practice covers thesettings.jsonreads but notmcp.json/skills_config.json, both of which are read after the LLM client attaches its file handler — those land inagentao.log.agentao doctorsurfaces all of them. - Write mode.
/skills disableand/skills enabledo a read-modify-write of one name, under afilelockonskills_config.json.lock(10s, the same protocolskills/registry.pyuses) that spans the read and the write, then swap the file in via a temp file andos.replace. Every other disabled name, and every other key in the file, survives — including ones a second agentao process wrote after this one started; "already disabled" / "is not disabled" is answered from the file, not from the set this process read at construction. The write path parses strictly, unlike the load above: malformed JSON, a non-object top level, a non-arraydisabled_skills, a non-string entry inside it, or a decode error each refuse the write and return an error naming the path — the lenient view the loader falls back to would, written back, delete exactly the names the file holds. A lock timeout or a failed write does the same. On any of those the file, the in-memory set and the active skills are all left untouched, and nothing reports success. The lock binds agentao processes following this protocol; a text editor saving the file at the same moment is outside it.
{
"disabled_skills": []
}| Key | Type | Notes |
|---|---|---|
disabled_skills |
string[] | Skill names to exclude from auto-discovery. Use /skills disable <name> from the CLI to manage. A disabled skill is also refused at activation — by the activate_skill tool, by /skills activate, and in any sub-agent, which inherits the set as it stands when the sub-agent starts (a disable made while one is running reaches the next sub-agent, not that one). The tool answers "Unknown skill" (to a caller trying to activate it, a disabled skill is not there); /skills activate names the state and the remedy. Re-enable it (/skills enable <name>) before activating. The list is never reconciled against what a scan found: a reload that cannot see the skill (unmounted share, directory renamed mid-session) leaves the name in place and writes nothing, so the skill comes back still disabled — a scan cannot tell "deleted" from "not discoverable right now", and since a disable is an activation gate, guessing wrong would silently re-arm the skill. Only /skills disable / /skills enable change the list; the cost is that a name can outlive the skill it disabled — /skills lists such a name under Disabled, not found by the last scan, and /skills enable <name> clears it whether or not the skill is present. |
See developer guide 5.2 for skill discovery and activation rules.
- Path.
<cwd>/AGENTAO.md. Optional. When it is absent,<cwd>/AGENTS.md(the cross-tool agents.md convention) is read instead. The first file that exists is the only one used; the two are never merged, and there is no nested lookup below<cwd>. AnAGENTAO.mdthat exists but cannot be read does not fall through toAGENTS.md. - Loader.
prompts/helpers.py::load_project_instructions, called once at construction; content is prepended to the system prompt when present. - Trust. Either file is taken from the working directory as-is. A cloned repository, or a workdir holding files a tenant uploaded, can therefore put instructions into the system prompt — and
AGENTS.mdis far more common thanAGENTAO.md. A host that does not want any file read passesAgentao(project_instructions=""), which skips both. - Schema. Free-form Markdown; no required structure.
See developer guide 5.6 for prompt-composition rules and conventions.
- Paths.
- Project:
<cwd>/.agentao/memory.db - User:
~/.agentao/memory.db
- Project:
- Format. SQLite; schema owned by
memory/manager.py::MemoryManager. Not hand-edited. - Deletion is soft, so the file does not shrink.
/memory delete,/memory clearandMemoryManager.wipe_all()setdeleted_at; the row keeps itscontentand stays inmemory.db, and no read path returns it again. Only your ownDELETE+VACUUM, or disposing of the file, removes the bytes — relevant if you are building an erasure guarantee on top of a wipe. Session summaries, by contrast, are a realDELETE. - Precedence. Both DBs are read independently; both are visible to the prompt renderer. Project memory does not override user memory.
- No user store configured. A
user-scope write is stored asprojectinstead — the behaviour a bareAgentao(...)has always had, since its manager is built with a project store only. It is no longer silent: an explicitscope="user"logs a warning, an inferred one (user_key prefix,preference/profiletag) logs at debug intoagentao.log. Neither line carries the key or the value. To get user scope, construct the manager with auser_store=—agentao.embedding.build_from_environment()does. (#260) - Sub-agents write here too — and read nothing from here. A sub-agent's
save_memorywrites through the parent's manager, so a memory it saves lands in the same stores, at the same scope, and through a host-injected manager if there is one (#260). Everything else about its memory is its own: since #234 it is built on a transient (:memory:) store, so its session summaries and its crystallized review proposals stay there and are discarded with it — they used to land in the project DB above, because the child's default opened the very same file. That store is also empty, which is the read side of the same decision: a sub-agent gets no<memory-stable>block and no recall. It is briefed by the parent's recent messages (parent_context), which carry no memories, so it can save a long-term memory it cannot read back. Rows a sub-agent wrote into the project DB before #234 carry no marker;/clearand/memory clearremove every session summary and every memory but reach no review item. (#234)
Full schema, tables, and lifecycle → developer guide 5.5.
- Paths. No fixed location —
agentao run --spec PATH(YAML or JSON) or piped on stdin. - Loader.
agentao/cli/run.py::_load_spec+_parse_spec_text→RunSpec.model_validate. Templating:cli/run_template.py::render_spec. - Scope. Per-invocation; not merged with any global file.
| Key | Type | Notes |
|---|---|---|
prompt |
str |
Required at run-time (after CLI merge + render). Templated. |
instructions |
str? |
Templated. When non-empty after .strip(), routes to Agentao(project_instructions=…) and short-circuits AGENTAO.md. |
parameters |
list[RunParameter]? |
See sub-schema below. Duplicates rejected. |
cwd |
str? |
Working directory; defaults to os.getcwd(). |
model / base_url |
str? |
LLM overrides. |
permission_mode |
enum | read-only / workspace-write / full-access / plan. |
interaction_policy |
"reject" | "deny" |
Default reject: a call that needs approval ends the run (exit 3). deny (0.5.16) declines it back to the model and lists it in the result's denials. ask_user ends the run under both. |
max_denials |
int? |
deny only (else exit 2). Calls declined back to the model before the next one ends the run as reject. Default 5, >= 0. |
permissions |
{allow, deny} |
Lists of RunPermissionRule. Spec writers never author action: — the loader injects it. |
max_iterations |
int? |
Default 100. |
skills |
list[str]? |
Must already exist; missing skills exit 2. |
thinking |
str? |
Thinking depth, as /thinking takes it: a level, or off to clear one LLM_EXTRA_BODY set. Written into the field the wire reads (agentao/llm/thinking.py). --thinking wins. More than one word, or an anthropic-messages effort outside low|medium|high|xhigh|max, exits 2. A YAML bare off is accepted. |
replay |
bool? |
Authoritative; bypasses the factory's disk auto-load when set. |
output |
{format: "text"|"json"}? |
Same effect as --format. |
extra="forbid" on RunSpec — unknown top-level fields fail loudly.
| Key | Type | Notes |
|---|---|---|
name |
str |
ASCII identifier ([A-Za-z_][A-Za-z0-9_]*) AND not a Jinja-reserved name (constants true/True/false/False/none/None, keywords for/if/in/set/..., runtime-injected self/parent). |
required |
bool |
Mutually exclusive with default. |
default |
str? |
String-only in v1. Must be in choices if both are set. |
choices |
list[str]? |
Enum-style validation. |
extra="forbid" on RunParameter — typos like requierd: fail validation.
- Repeatable. Splits on the first
=only — value may contain further=chars. - Empty key or missing
=→ exit 2, "expected KEY=VALUE". - Duplicate key → exit 2, "supplied multiple times" (no last-wins).
- Non-identifier key → exit 2 with the identifier-rule message.
-
Jinja2
SandboxedEnvironment+StrictUndefined+keep_trailing_newline=True,autoescape=False. -
Only
spec.promptandspec.instructionsare rendered. -
Trigger rule (no Jinja2 call when both empty):
spec.parametersCLI --paramBehavior empty / unset empty No-op (literal {{ }}passes through).empty / unset non-empty exit 2 (unknown parameter). non-empty any Validate + render. -
Render errors → exit 2 /
invalid_spec:SecurityError→ "sandbox-blocked operation".UndefinedError(StrictUndefined) → "template uses undefined variable 'X' (declare it in spec.parameters)".- Anything else from
template.render()(e.g.ZeroDivisionError,TypeError,TemplateNotFound) → "template error in spec.<field>: …".
| Code | Meaning |
|---|---|
| 0 | OK |
| 1 | Runtime error, or a turn that produced no answer (runtime_error, empty_response, length_truncated, doom_loop, hook_stop) |
| 2 | Invalid usage / invalid spec |
| 3 | Permission denied or interaction required |
| 4 | Max iterations |
| 130 | Interrupted (SIGINT) |
error.type discriminates outcomes that share an exit code. Every turn the
runtime classifies as having no complete answer (the six reason values
below) exits 1 rather than 0, so a pipeline cannot read those as success — this
is a single closed vocabulary with no unclassified ending. error.reason
carries the exact variant — branch on reason, not on message:
type |
reason |
Meaning |
|---|---|---|
empty_response |
no_output |
The model emitted nothing. |
empty_response |
reasoning_only |
Reasoning, but no answer text. |
length_truncated |
length_truncated |
Output cut off at the token limit — a tool call whose arguments were truncated (the harness then halts the turn), or a final answer cut off mid-sentence. |
doom_loop |
doom_loop |
The model repeated one call until the detector halted the turn. |
hook_stop |
hook_stop |
A plugin hook ended the turn. PreToolUse and PostToolUse / PostToolUseFailure reach this with continue: false; UserPromptSubmit reaches it by blocking or stopping the prompt, before the model is called. |
runtime_error |
llm_error |
The LLM API call failed (provider error / rate limit / auth) after retries; the message carries the provider's actual error. |
empty_response means the model said nothing; length_truncated / doom_loop
mean the harness halted a non-converging turn; hook_stop means your own
configuration stopped it deliberately; llm_error means the provider call
failed. All are classified last, so a rejection, max-iterations, cancellation,
or raised error keeps its own more specific code.
max_iterations is a seventh reason value on agent.last_turn, but it never
reaches this table: it also flips a sticky transport flag that
_classify_outcome checks first, so agentao run exits 4 for it, not
1.
A turn can end this way after running tools — the model may do the work and
then fail to summarize it. tool_calls reports how many ran and their side
effects have already landed, so an empty_response failure does not imply
the workspace is untouched.
Full design rationale → run-spec-parameters.md.
Shell commands agentao runs at eight points in a turn. The file travels with a
plugin rather than living under .agentao/: it is discovered at
<plugin>/hooks/hooks.json when the manifest does not declare hooks, and the
manifest's hooks key may instead give a path, an array of paths, or an inline
object.
Two contracts, chosen per file by its shape — a file copied out of a Claude
Code setup carries no marker to gate on, so the parser looks at the entries
themselves. An entry with a hooks: [] array and no type is the official
shape; an entry with type and no array is agentao's flat shape. A file
whose entries disagree is rejected whole (it claims to be both); a single entry
with neither key is a malformed handler and is warned about individually, so one
typo cannot disable a working file. An explicit "contract" key overrides the
detection; an unknown value disables the file.
| Contract | Shape | Status |
|---|---|---|
agentao-v1 |
flat: handlers directly under the event array | Frozen. Behaves exactly as it did before 0.4.21 |
claude-code@profile-1 |
official: event → matcher group → hooks[] → handler |
An enumerated profile of the Claude Code hook contract |
{
"PreToolUse": [
{
"matcher": "Read|Write",
"hooks": [
{ "type": "command", "command": "./guard.sh ${CLAUDE_PLUGIN_ROOT}", "timeout": 600 }
]
}
]
}| Key | Required | Default | Notes |
|---|---|---|---|
| (event name) | yes | — | One of PreToolUse, PostToolUse, PostToolUseFailure, UserPromptSubmit, Stop, SessionStart, SessionEnd, PreCompact. Any other event is not in the profile |
matcher |
no | match all | A string. re.fullmatch semantics, with * and "" as wildcards — ead does not match Read. Compared against the tool name on the tool events, source on SessionStart, reason on SessionEnd, trigger on PreCompact. A pattern that is not a valid regex is compared as literal text and reported as a plugin warning when the file loads |
hooks[].type |
yes | — | command only. prompt, agent, mcp_tool, http are skipped with a warning (agentao's prompt hook is a different feature wearing the same name) |
hooks[].command |
yes¹ | — | Shell string, run under /bin/sh |
hooks[].args |
no | — | Exec form; use instead of command to skip the shell |
hooks[].timeout |
no | 600 (30 for UserPromptSubmit) |
Seconds |
hooks[].shell |
no | — | Ignored with a warning. Measured: Claude Code 2.1.251 runs command hooks under sh and does not honor it either |
hooks[].async, asyncRewake, if |
no | — | Rule skipped with a warning — agentao has no background hook runner, and if is a permission sub-feature rather than a field |
hooks[].once, statusMessage |
no | — | Recognized and inert |
¹ command or args; a handler with neither is skipped.
What SessionStart / SessionEnd actually report. These are the values a matcher on those
two events is compared against, so a rule matching anything else never fires:
| Entry point | SessionStart.source |
SessionEnd.reason |
|---|---|---|
| Interactive launch | startup |
— |
Interactive exit (/exit, /quit) |
— | prompt_input_exit |
agentao --resume (successful load) |
resume |
— |
agentao --resume (failed load, CLI starts anyway) |
startup |
— |
/sessions resume (successful load) |
resume |
resume |
/sessions resume (failed load) |
— | — |
/clear and /new |
clear |
clear |
agentao run |
startup |
other |
¹ Hook dispatch only. /sessions resume does not persist the outgoing conversation, which it never
has; the event reports the boundary without changing what the command saves.
Over ACP the same two events fire, on their own paths:
| ACP path | SessionStart.source |
SessionEnd.reason |
|---|---|---|
session/new |
startup |
— |
session/load, and a startup --resume consumed by the first session/new |
resume |
— |
a startup --resume that finds nothing and falls back to a new session |
startup |
— |
| a session actually closing (client disconnect, server shutdown) | — | other |
| a failed load, a duplicate load, a cancelled turn | — | — |
Creating or loading a session does not end another one — ACP holds several at once — and
cancelling a turn is not a session ending. SessionStart fires after history is restored and before
the session can take a turn, so an additionalContext a hook injects is in front of the first
prompt. Its user notices (exit 2) arrive as a session/update chunk, best-effort: on session/new
the notice precedes the response that tells the client the new sessionId, and on close the stream may
already be gone.
SessionStart also fires with source: "compact" after a successful full compaction — manual
/compact, the automatic threshold tier, and the first API-overflow rung. It does not fire for
microcompact or minimal_history, which trim rather than rebuild, nor for a compaction that was
cancelled, failed, or skipped. The session id does not change, no SessionEnd is emitted, and no
replay or memory-session boundary is crossed: only the hooks run. Context a hook injects is in place
before the next model request is assembled, including the immediate retry the overflow rungs make.
fork is never emitted: agentao has no thread fork. See
docs/design/session-lifecycle-source-vs-codex.md.
Unknown keys are ignored with a one-time diagnostic naming them, never a schema error. A hook written for a newer Claude Code keeps working and its author is told which key had no effect. The diagnostic is session-scoped and keyed by rule content, so a plugin reload lets a corrected hook speak again.
{ "PreToolUse": [ { "type": "command", "command": "./guard.sh", "timeout": 60 } ] }Handlers sit directly under the event; matcher is a dict (glob on
toolName), the timeout default is 60, and type: "prompt" is supported.
Nothing in 0.4.21 changed this shape's behaviour.
${CLAUDE_PROJECT_DIR}, ${CLAUDE_PLUGIN_ROOT} and ${CLAUDE_PLUGIN_DATA}
(the last rooted at ~/.agentao/plugin-data/<plugin>) are substituted in the
command and exported to the child. The child environment is built by
capabilities/process.py::build_child_env, which strips agentao's own
provider credentials — a hook that needs them must be given an explicit
env= by its caller or run with AGENTAO_SCRUB_CHILD_ENV=0.
| Tier | Limit | On overflow |
|---|---|---|
| Raw capture | 8 MiB per stream per invocation | Process tree killed, hook fails — output cut mid-JSON has no decision to contribute |
| Delivered channel | 10,000 characters | Truncated, full text spilled to .agentao/hook-outputs/ |
Spill files are 0600 and secret-scanned before the bytes land, pruned at
7 days / 200 files. A failed spill is reported rather than silently skipped.
Both contracts may appear in one session (in different files). Rules are
partitioned by contract, run, and merged once over the event's decision
lattice — deny > ask > allow on PreToolUse — with the surfaced reason
ranked inside the winning class by declaration order. A v1 short-circuit
ends only the v1 group: the profile's "every matching handler runs" rule exists
for handlers with side effects. A Stop hook's reentry cap follows the
contract of the rule that produced the continuation — 8 under the profile,
3 under agentao-v1.
When adding a new config file, update both:
- This file (row in §1, full section like §3–§10).
- The corresponding feature doc — keep the why and how to use there, not here.
Checklist:
- Row added to §1 with path, scope, loader, feature-doc link.
- Schema documented with required/optional/default for every key.
- Precedence rule stated if both project and user variants exist.
- Loader file path included so readers can verify behavior in code.
- Feature doc cross-link added (or
<!-- TODO -->if the doc does not exist yet).