Skip to content

Latest commit

 

History

History
752 lines (591 loc) · 83.2 KB

configuration.md

File metadata and controls

752 lines (591 loc) · 83.2 KB

Configuration Reference

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 (or agentao doctor for plugin / ACP / optional-dep coverage as well). Both support --json; errors exit 1, warnings exit 0. See developer-guide CLI ch. 12.


1. Configuration surfaces at a glance

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 through security/secret_scan.py::scan_and_redact before it reaches disk — credential-shaped strings become [REDACTED:<kind>]. .agentao/sessions/*.json (embedding/sessions.py::save_session) and .agentao/background_tasks.json are not scanned, and that is a trade rather than an oversight: both round-trip back into agent.messages on 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.json is 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 in full-access / plan modes, 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.json from 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.

2. .env — LLM provider configuration

  • Path. <cwd>/.env. Loaded by dotenv.load_dotenv() at the top of embedding/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 (default OPENAI) selects which {PROVIDER}_API_KEY / {PROVIDER}_BASE_URL / {PROVIDER}_MODEL triple is read, plus that block's optional {PROVIDER}_API_FORMAT.

Schema

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.example in the repo root.

2a. ~/.agentao/llm.json and ACP session resolution (0.5.8)

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):

  1. Layers, highest first: the server's launch environment (snapshotted at startup) → the session's <cwd>/.env (no search above cwd; ${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.
  2. Provider first: the highest layer that names one (LLM_PROVIDER, or the file's provider), else OPENAI. Whether a layer holds an API key plays no part in the choice.
  3. Fields second: {PROVIDER}_MODEL and the shared LLM_* variables, layer by layer, for the chosen provider only. The file contributes only when its provider matches.
  4. Key, base URL and wire format together: {PROVIDER}_API_KEY, {PROVIDER}_BASE_URL and {PROVIDER}_API_FORMAT come 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 an OPENAI_API_KEY exported 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's anthropic-messages never reaches a project's OpenAI-compatible gateway. Put a key, its URL and its format in the same place.
  5. Snapshot: the result is fixed on the session. A session/set_config_option provider 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.


3. .agentao/settings.json — runtime mode + builtin agents

  • Path. <cwd>/.agentao/settings.json (project-only, no user variant).
  • Loaders.
    • embedding/factory.py::_load_settings — reads agents.enable_builtin / enable_builtin_agents to default the constructor's enable_builtin_agents flag.
    • plan/controller.py::_load_settings — reads mode when restoring the active permission mode after a plan-mode session ends.
    • cli/app.py::_read_auto_wake — reads background_agents.auto_wake once 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 as utf-8-sig, so a BOM'd file loads rather than being discarded. /replay on / /replay off write this file back, so they refuse instead: when it cannot be read as a JSON object, or holds a number JSON cannot represent outside replay.max_instances, they print an error and leave the file unchanged, since writing over it would drop every other key. /mode writes 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-finite replay.max_instances (1e309 reads as infinity) is written back as the count in effect, 20. The warning goes to the agentao logger: it reaches the terminal only while no handler is attached yet (Python's lastResort), which in practice covers the settings.json reads but not mcp.json / skills_config.json, both of which are read after the LLM client attaches its file handler — those land in agentao.log. agentao doctor surfaces all of them.
  • Important. The factory does not apply mode to the engine on startup; the PermissionEngine always initializes at workspace-write. The mode field is the persisted last-known mode used for restoration paths and CLI inspection — runtime mode changes go through CLI commands or PermissionEngine.set_mode().

Schema

{
  "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).

/goal state itself is not stored in settings.json — it lives in .agentao/goal.json (see §1 file table). Per-goal caps set with /goal <obj> --for/--turns or /goal budget override these defaults; --unbounded opts out of them entirely.

See developer guide 5.4 for what each mode actually permits.

Tool-set selection is construction-only — not a settings.json field (v1). Which tools the model sees is chosen at Agentao(...) 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 with disable_tools.
  • disable_tools={...} — subtractive: skip named built-ins.
  • extra_tools=[...] — inject pre-built Tool / AsyncToolBase instances.

These are pure data (names) or instances, not JSON surfaces — wiring them through settings.json is deliberately deferred (demand-gated; see host-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 filesystem and shell;
  • MCP tools, which run over the parent's connections, so a sub-agent reads no mcp.json and launches no server of its own;
  • complete_task, which it always gets.

It gets a host tool (extra_tools=, add_tool, a bare register) only when the tool object declares copies_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 whose tools: list names something it does not get logs a warning; the list still applies to a declared host tool. See agents/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 an agentao run spec'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.


4. permissions.json — per-tool permission rules

  • Paths.
    1. ~/.agentao/permissions.json (user-level) — the only file-based rule source.
    2. <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 given rules= (as Agentao(permission_mode=) does); built with rules=None it 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 (rules is the only legal one, so {"rule": [...]} is rejected rather than silently loading zero rules), or a rule that fails validation — raises PermissionConfigError naming the path, and session construction aborts. The asymmetry with every other config file is deliberate: a dropped deny on a shell or web tool degrades to ask, and a dropped deny on an mcp_* tool degrades to nothing at all (the engine returns no decision, the runtime falls through to the tool's own requires_confirmation, and a trust: true server's tool then runs unprompted). agentao doctor reports the same failures without aborting. Read as utf-8-sig, so a BOM'd file loads.
  • Provenance. A file that loads successfully contributes a loaded_sources label (user:<path>) returned from PermissionEngine.active_permissions() and Agentao.active_permissions() — see docs/reference/host-api.md.
  • Public getter. PermissionEngine.active_permissions() returns a cached, JSON-safe ActivePermissions snapshot (mode, rules, loaded_sources). Hosts that layer policy on top can call add_loaded_source("injected:<name>") so the snapshot reflects their provenance. The cache is invalidated on set_mode() and add_loaded_source().
  • Evaluation order.
    • Modes read-only / workspace-write: [user rules] → [active mode preset rules] (first match wins). In read-only this 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() returns None; the runner falls back to the tool's own requires_confirmation attribute.

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 via add_loaded_source("injected:<name>") plus your own rule layer.

Schema

{
  "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-allows write_file / replace, except a target under a .git or .agentao directory (or a .git file) at any depth, which asks: .git/config can make the next git status run a command (core.fsmonitor), and .agentao/ holds MCP servers, hooks and plugins. Credential files ask too, at any depth: .env and .env.* (not .env.example, .sample, .template or .dist), *.pem and *.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-diff or --textconv (or an abbreviation of one); anything else asks. Drivers already present in git configuration can still run from those git commands; denies rm -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; ToolRunner short-circuits on tool.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_mode over ACP, an embedded host's set_mode). Read-only tools are the readers plus activate_skill and todo_write, which change only session state; save_memory writes 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 to workspace-write.

Full rule taxonomy, examples, and runtime semantics → developer guide 5.4.

The shell block — choosing the interpreter

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:

  • -NoProfile means 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 N and 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 \ufeff before 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 \ufeff if 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's utf8 is 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).


5. mcp.json — MCP server registry

  • Paths (load order).
    1. ~/.agentao/mcp.json (user-level) — authoritative for any name it declares.
    2. <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 in env, headers, args and oauth.client_secret are expanded ($VAR and ${VAR}). An unset or empty variable in headers or oauth.client_secret keeps that server from connecting, with an error naming the variable: "Authorization": "Bearer ${TOKEN}" with TOKEN unset would otherwise send Bearer , and the Authorization header turns OAuth off. An unset variable in env or args expands 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-srv and my_srv) are not both registered: the first one listed keeps the name, and the other is left out with an error in agentao.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 add and /mcp remove write the project file back, so they refuse instead: when it cannot be read as a JSON object, its mcpServers is 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_config raises McpConfigWriteError in the same cases. The warning goes to the agentao logger: it reaches the terminal only while no handler is attached yet (Python's lastResort), which in practice covers the settings.json reads but not mcp.json / skills_config.json, both of which are read after the LLM client attaches its file handler — those land in agentao.log. agentao doctor surfaces all of them.

Schema

{
  "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.

env and the base environment. A stdio server's env entries are applied on top of a scrubbed copy of agentao's environment: mcp/client.py builds the child env via build_child_env(), which drops agentao's own provider credentials (HARNESS_ENV_KEYS). An MCP server that previously relied on inheriting GEMINI_API_KEY, OPENAI_API_KEY, etc. now gets a 401 — pass the key explicitly in env ({"GEMINI_API_KEY": "$GEMINI_API_KEY"}, applied after the scrub) or set AGENTAO_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 — send User-Agent: agentao-mcp/<version> so agentao is identifiable in server logs and isn't filtered as an unknown client. Override it by setting a User-Agent entry in headers (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 url server 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 mcp 2.2 or later installed — what a fresh install resolves to, since agentao accepts mcp>=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, or http → https on the same host). Any other redirect, including one to another port, fails the connect with Redirect to <url> not followed; use that URL as the endpoint…: put that URL in url. A redirect from https down to http fails with Redirect to <url> not followed: it would downgrade this HTTPS endpoint… instead, and names the https URL to try — use that one, not the plain-http location. Changing type does 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 the initialize() / 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. startup is the connect/handshake bound above (default 60); request bounds each tool call after init (omit → unbounded). Without request, 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 — set request to cap it deterministically (a request above 300 s also raises that read timeout to match). A call past request fails 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.


6. .agentao/acp.json — ACP subagent registry

  • Path. <cwd>/.agentao/acp.json only. No user-level variant — ACP servers are explicitly project-scoped.
  • Loader. acp_client/config.py::load_acp_config (parsed into AcpServerConfig via acp_client/models.py::AcpServerConfig.from_dict).
  • Failure mode. Missing command / args / env / cwd raises AcpConfigError at 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 as AcpConfigError, never as a raw traceback. Read as utf-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 inheriting OPENAI_API_KEY now gets a 401; declare the key in its env block instead, or set AGENTAO_SCRUB_CHILD_ENV=0 to restore full inheritance process-wide.

Schema

{
  "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.


7. .agentao/skills_config.json — disabled-skills list

  • 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_skills value 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 as utf-8-sig, so a BOM'd file loads. The warning goes to the agentao logger: it reaches the terminal only while no handler is attached yet (Python's lastResort), which in practice covers the settings.json reads but not mcp.json / skills_config.json, both of which are read after the LLM client attaches its file handler — those land in agentao.log. agentao doctor surfaces all of them.
  • Write mode. /skills disable and /skills enable do a read-modify-write of one name, under a filelock on skills_config.json.lock (10s, the same protocol skills/registry.py uses) that spans the read and the write, then swap the file in via a temp file and os.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-array disabled_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.

Schema

{
  "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.


8. AGENTAO.md — project instructions

  • 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>. An AGENTAO.md that exists but cannot be read does not fall through to AGENTS.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.md is far more common than AGENTAO.md. A host that does not want any file read passes Agentao(project_instructions=""), which skips both.
  • Schema. Free-form Markdown; no required structure.

See developer guide 5.6 for prompt-composition rules and conventions.


9. Memory stores (memory.db)

  • Paths.
    • Project: <cwd>/.agentao/memory.db
    • User: ~/.agentao/memory.db
  • Format. SQLite; schema owned by memory/manager.py::MemoryManager. Not hand-edited.
  • Deletion is soft, so the file does not shrink. /memory delete, /memory clear and MemoryManager.wipe_all() set deleted_at; the row keeps its content and stays in memory.db, and no read path returns it again. Only your own DELETE + 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 real DELETE.
  • 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 as project instead — the behaviour a bare Agentao(...) has always had, since its manager is built with a project store only. It is no longer silent: an explicit scope="user" logs a warning, an inferred one (user_ key prefix, preference / profile tag) logs at debug into agentao.log. Neither line carries the key or the value. To get user scope, construct the manager with a user_store= — agentao.embedding.build_from_environment() does. (#260)
  • Sub-agents write here too — and read nothing from here. A sub-agent's save_memory writes 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; /clear and /memory clear remove every session summary and every memory but reach no review item. (#234)

Full schema, tables, and lifecycle → developer guide 5.5.


10. Run spec — agentao run invocation

  • 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.

Schema (top-level)

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.

parameters[] sub-schema (RunParameter)

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.

--param KEY=VALUE (CLI)

  • 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.

Templating

  • Jinja2 SandboxedEnvironment + StrictUndefined + keep_trailing_newline=True, autoescape=False.

  • Only spec.prompt and spec.instructions are rendered.

  • Trigger rule (no Jinja2 call when both empty):

    spec.parameters CLI --param Behavior
    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>: …".

Exit codes (agentao run)

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.


11. Plugin hooks — hooks/hooks.json

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

Schema — official shape (claude-code@profile-1)

{
  "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.

Schema — flat shape (agentao-v1, frozen)

{ "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.

Placeholders and the child environment

${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.

Output limits

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.

Precedence

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.


Appendix A — Adding a new configuration surface

When adding a new config file, update both:

  1. This file (row in §1, full section like §3–§10).
  2. 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).