Skip to content

Harness configuration

Status: planned (S13 configurator, S18 role scopes), except the worker Skill at launch, a source candidate. Harness facts below must be re-checked against each harness version when its adapter is built. Decisions: Maia configures every harness, skills CLI as installer.

Goal

The user configures skills, instructions, MCP servers, subagents, hooks and permissions once in Maia. Maia keeps every harness it manages (Claude Code, Codex, Pi, Cursor, later others) equivalent at user and project level, on every Node, so an agent behaves the same whichever harness or machine runs it.

Maia supplies operations, not decisions (decision). Keeping environments in sync is a Skill or a scheduled routine that calls the operations below; the Node only applies explicit rules and never overwrites what a person wrote by hand.

Sources, layers and scopes

  • Sources. ~/.maia (user) and <project>/.maia (project, versioned) hold the intent. Skill pins are the exception: they are the skills CLI's lock files (Skills). Per-OS and per-Node overrides live in the same files. Runtime state, including the ownership manifest, lives in the Maia home's runtime state, never in .maia.
  • Layers. Built-in < user < project < Session or turn. Each item is versioned and pinned by digest.
  • Role scopes (S18). Shared items reach the user's harnesses and every worker. Orchestrator-only items (the maia-orchestration Skill) reach only the user's plugin. Worker-only items (the maia-worker Skill) are injected at launch into worker Sessions and are never written to shared directories.
  • Secrets are never copied between harnesses or Nodes. Configuration refers to environment variables; each Node signs in to its own accounts.

Operations

All are registry operations with SDK, Node, CLI and MCP bindings.

OperationKindPurpose
config.resolvereadThe effective configuration for a harness, scope, Project and Node
config.planreadThe exact changes apply would make, per harness and file, with the reason for each and anything that cannot be represented
config.applymutationPreview, confirmation, compare-and-set, receipt; backs up before any overwrite
config.driftreadManaged items whose content changed since Maia wrote them, and Nodes whose applied digest differs from the desired one
config.importmutationAdoption: proposes copying existing harness configuration into .maia, and Skills into the skills CLI's lock (D-170); never moves files

How an item reaches a harness

For each item, use the first route that works:

  1. Launch-scoped injection when Maia launches the harness. Nothing reaches the user's global configuration; an adapter may stage a private or worktree file the harness needs (the worker Skill). This is the only route for worker-only items.
  2. Maia-owned whole files in directories the harness merges (skills and agents directories, ~/.claude/rules/maia.md, .cursor/rules/maia-*.mdc, Pi settings path arrays).
  3. Keyed entries in shared structured files, through the harness's own CLI (claude mcp add, codex mcp add, pi mcp add) or a format-preserving editor with an atomic write.
  4. Marked blocks in single-file instructions, only when nothing above works (<!-- maia:begin <id> --> … <!-- maia:end <id> -->).
SurfaceClaude CodeCodexPiCursor
Skills, native~/.claude/skills, .claude/skills~/.agents/skills, .agents/skills, .codex/skills~/.pi/agent/skills, .pi/skills, .agents/skills.cursor/skills, .agents/skills (also reads .claude/skills)
Skills, at launch--plugin-dir; SDK plugins, skillsapp-server skills/extraRoots/set--skill; SDK additionalSkillPaths--plugin-dir
InstructionsCLAUDE.md, .claude/rules/*.md (@ imports)AGENTS.md per directory, 32 KiB capAGENTS.md, APPEND_SYSTEM.md.cursor/rules/*.mdc, AGENTS.md
MCP.mcp.json, ~/.claude.json (JSON)[mcp_servers] in config.toml (TOML)~/.pi/agent/mcp.json, .pi/mcp.json~/.cursor/mcp.json, .cursor/mcp.json
Subagents.claude/agents/*.mdagents/*.tomlnone (prompt templates).cursor/agents/*.md
Hookssettings hookshooks.json (needs trust)none (extensions)hooks.json (camelCase events)
Permissionspermissions.allow/ask/denyapproval and sandbox policy, rules/*.rulesnoneCLI permissions.allow/deny
Home overrideCLAUDE_CONFIG_DIRCODEX_HOMEPI_CODING_AGENT_DIRnone documented

Portable formats. Skills use the Agent Skills fields (name equal to the directory, description, license, compatibility, metadata) plus disable-model-invocation, with relative paths in bodies; Claude-only body syntax is not portable. MCP is emitted as JSON or TOML per harness, with the harness's own environment-reference syntax (Codex has none). Permissions have no common model: each adapter translates and config.plan reports what loses meaning. Emit the smallest file set that covers each harness once (.agents/skills covers Codex, Pi and Cursor; .claude/skills covers Claude) and check that no harness loads one Skill twice.

Skills: installer and discovery

Skills owns this (D-040, D-170). In short, config.apply runs the pinned skills CLI with -a for each harness the Project's Agents use on the Node, the CLI's skills-lock.json is the only pin, and config.drift reports Skills that are modified or missing against it. It covers shared and orchestrator-only Skills. Worker-only Skills are still injected at launch, and MCP, instructions and permissions still go through Maia's own adapters.

Before the first release, verify and pin with tests: it never overwrites a hand-written Skill of the same name; its exit codes; skills list --json, both lock formats and the folder hash; no Skill loads twice when both .agents/skills and .claude/skills exist (Cursor reads both); behavior offline and on Windows. A missing network or npm cache fails config.apply with a stated reason.

Worker Skill at launch

Status: source candidate (S22, decision). Every coding Session (S5) gets the maia-worker Skill before each launch, whatever its Connection (Maia-managed, system default or gateway); reading Sessions have no shell, so they get none, except an orchestrator, which gets the Skill whatever its access: Claude as the launch plugin, Codex as developer instructions, and no worktree copy. The reviewed copy is plugins/maia/worker-skills/maia-worker/SKILL.md (at most 2 KiB), outside the orchestrator plugin's skills folder so the user's own harness never loads it. It ends with a short orchestrator-only paragraph (D-151); a worker's MCP server lists no child tools. The Node embeds the same bytes, a test keeps them equal, and another checks its MCP tool names against the worker role table.

  • Codex: the Node writes <worktree>/.agents/skills/maia-worker/SKILL.md (the folder Pi and Cursor also read, though they are not Maia executors yet) and adds /.agents/skills/maia-worker/ to the repository's .git/info/exclude, which Git reads for every worktree of the repository. It changes that file only when it is a regular file, keeps its lines, and replaces it whole through a staged copy. It writes the Skill only when Git then ignores the path (never over a file the repository tracks or re-includes), never through a link, and replaces a copy a worker changed at the next launch. The worktree's git status stays clean; archive and diff also exclude the Skill by pathspec if the ignore line is gone; pruning removes it with the worktree. A Skill that cannot be written this way is left out with a warning, and the launch goes on. When the copy is written, Codex also receives the Skill's body (without its front matter) as -c developer_instructions=..., because every Codex worker launches with skills.include_instructions=false, which drops the Skills list from its prompt (decision). This replaces any developer_instructions from the user's Codex configuration. Codex fixes a thread's developer instructions at its first turn, so a Skill change reaches new threads alone; a resumed thread keeps what it started with. connection detect checks that the installed Codex still honours the prompt keys (see the CLI reference).
  • Claude Code: the Node writes a plugin (.claude-plugin/plugin.json named maia, plus skills/maia-worker/SKILL.md) in its private state, one folder per Skill digest, and passes it as plugins: [{ path, skipMcpDiscovery: true }] (--plugin-dir-no-mcp, the one plugin flag the spawn check admits, for that exact folder) with skills: ["maia:maia-worker"] and the Skill tool, which the PreToolUse guard limits to that Skill. The plugin follows the worktree's outcome, so session show stays true for both: a Claude Session gets the worktree copy too (unused) and no plugin when that copy was left out. A plugin that cannot be written fails the launch, because the record already names the Skill. A real claude that loaded it wrote only its usual Session state in its CLAUDE_CONFIG_DIR, no plugin or Skill.
  • Nothing reaches ~/.claude, ~/.codex, ~/.agents or a Maia-managed account root.
  • .maia/worktree.json with "workerSkills": false turns the Skill off for the Project's new coding Sessions (read at creation, like copy and setup).
  • session show (and MCP session_show) lists what the latest launch injected in worktree.skills as { name, digest }; the field is absent when the Project turned worker Skills off.

Why these routes (observed on 3 October 2026, without a model turn):

  • Claude Code 2.1.287 under Maia's worker profile (settingSources: []) loads no Skill from CLAUDE_CONFIG_DIR/skills or .claude/skills; it lists them only with the user and project setting sources, which would also load settings, hooks and CLAUDE.md. A plugin passed at launch loads, and the Skill tool appears only when tools names it.
  • Codex 0.160 lists Skills from $CODEX_HOME/skills and from the repository's .agents/skills, but tells the model to open SKILL.md itself. Under maia-write the sandbox reads the worktree, not an account root under the home folder, so only the worktree copy is readable. The list re-sent each model call (the user's Skills included) cost a system-default worker about 5,000 tokens per call, so Maia turns it off and sends the Skill's body instead (about 300 tokens).
  • The skills CLI is not run at launch. Version 1.7.0 (npm integrity sha512-OfePnDft+Xt9/tCoHdCUe5fkM8i+Q3QOSQO53hm7mKtsXyvc+CKOAAliVWZ484HS3cWx+6r+ob0AArixs3jYXw==) writes .agents/skills/<name> for Codex and .claude/skills/<name> for Claude with add --copy, the first of which Maia writes itself. Running it would need the network or an npm cache on every launch, cannot pin its digest through npx, writes skills-lock.json into the worktree and ~/.agents/.skill-lock.json into the home folder, and its Claude target is not read by workers. It stays the installer for config.apply.

Worker MCP at launch

Status: source candidate (S18c, decision). Coding Sessions and orchestrator Sessions, whatever their access, receive the maia_worker stdio server in their vendor launch config, independently of the Project's Skill opt-out (D-151). A reading orchestrator keeps its read-only profile: only the MCP server and the Skill are added, never a write tool or a sandbox change. A reading worker gets neither. The Skill teaches MCP tools; the vendor launches the server outside its tool sandbox, keeping sandbox filesystem and network restrictions unchanged.

  • The Node resolves maia on its absolute PATH; a remote Node's unit PATH starts with its release's bin/, whose maia runs the CLI built with that release on the release's runtime (decision). It walks each entry the way exec would, one link hop at a time, and skips entries that do not exist, are not directories, cannot be searched or loop. A dangling maia link counts as absent; a dangling node link counts as absent unless the worker or another user could create its target. It refuses relative and world-writable entries, missing or unsearchable entries someone else could create, paths in the worker's checkout or private temp directory, entries the worker could redirect, and a maia or node that is world-writable or that the worker or another user could replace anywhere on its way. Every hop's directory and every ancestor counts: a world-writable one makes its entry replaceable unless it is sticky, like /tmp, the Node's user or root owns it and the Node's user owns the entry (decision). On macOS, worker paths match in any letter case. An entry another user could redirect is skipped and left out of the pinned PATH; stock macOS reaches /System/Cryptexes/App/usr/bin that way. Group-writable directories stay trusted, since package managers such as Homebrew install into them; their group is inside the trust boundary. Each refusal names the PATH entry or file and the fix. The no-maia error leads with the install hint and names the first entry it could not search; skipped entries ahead of the resolved maia are logged as a warning. A fixed /bin/sh command changes to /, sets PATH to the canonical directories it checked, so the CLI's env-node shebang searches only those whatever PATH the vendor passes on, and executes the resolved CLI with mcp --worker; this also prevents the development shim from selecting a worker checkout. Arguments are positional data, never shell source.
  • Codex gets -c mcp_servers.maia_worker={command=...,args=...,env_vars=..., enabled=true,required=true,enabled_tools=[...]}. Only the two Session variable names appear in that config. Discovery gets no credential or server, all user servers stay disabled, and a pre-existing maia_worker entry refuses launch. Startup notifications admit that exact name alone.
  • Claude gets SDK mcpServers with command and args, strictMcpConfig: true, and exact mcp__maia_worker__<tool> names in allowedTools and the PreToolUse guard. settingSources: [] and the Skill plugin's skipMcpDiscovery stay unchanged. The spawn guard accepts only the exact generated MCP JSON and refuses changed or duplicate config flags.
  • Both servers inherit the Session credential and loopback origin delivered by S18a. No credential enters config files or argv. --worker refuses missing Session variables; Node auth refuses expiry and revocation on every operation. Worker tools derive from the existing role table, including session_list restricted to the credential's Project, and session_message_send, session_inbox_check and session_inbox_ack, which act as the credential's own Session and today reach only Sessions of its own execution workspace (checkout); Project reach and mutual Workspace opt-in are planned (D-166). These three are not coordinator tools: the coordinator principal has no Session to send as.

Config formats were checked against Codex's official config schema, the repository's app-server schema reference, and Claude Agent SDK 0.3.285's McpStdioServerConfig, mcpServers and strictMcpConfig declarations and serialization. Tests cover the vendor launch boundaries with fakes, and the built MCP CLI against real SQLite and loopback. No real vendor model run qualifies this slice yet.

Ownership rules

  1. Manifest. Per machine: harness, scope, path, kind (file, directory, entry, block), key, source revision and the digest of what Maia wrote.
  2. Compare-and-set. Write only when the target is absent or still has the digest Maia wrote; re-read just before the atomic rename.
  3. Never delete what Maia did not write. Remove a managed item only while it is unchanged.
  4. Harness-owned keys stay untouched: Claude's local approvals, Codex's [hooks.state], project trust and tool approvals, Pi's /mcp toggles. They are overlays, not drift. Maia never writes a trust hash.
  5. Drift goes through preview and an explicit choice: adopt into .maia (a Skill into the skills CLI's lock), overwrite after a backup, detach, or skip.
  6. One Skill name everywhere, recorded in the manifest and metadata.maia-id.
  7. Copies, not symlinks, by default (harness edit tools, Windows and approval prompts break on symlinks); directories are staged and renamed into place.
  8. Merge, never replace. Edit keyed entries; never rewrite a whole MCP map or hooks list, and never leave .bak copies beside harness files. Each adapter ships recorded fixtures of its harness's files and a --check mode.

Projects, git and Nodes

  • Generated project files are committed by default, so new worktrees and teammates without Maia still work; CI checks them with config.drift. A source-only mode writes ignores to .git/info/exclude. Files people edit (.claude/settings.json, .codex/config.toml) are never ignored.
  • First run scans every known path read-only, classifies items as identical, conflicting or user-only, and offers config.import.
  • Each Node applies the control Node's desired configuration (a versioned digest) with its own adapters and overrides, and reports the applied digest, drift, missing tools or versions, and missing accounts.
  • After a write, live Sessions are told where the harness allows it (reloadPlugins, setMcpServers, config/mcpServer/reload, skills/list); otherwise the change applies to the next Session.

Keeping harnesses current

Maia adapts to the harness CLIs on the Node's machine (D-148); it never runs a vendor updater.

  • connection detect reports installation and approval, whether the approved build launches, and freshness as separate facts, each allowed unknown. The Node adds them only when the request asks (upkeep: true), so an older client keeps decoding detection.
  • connection detect --refresh asks a fixed public registry for the newest version. The answer is advisory and bounded. It records the exact version, source and check time, and the Node keeps it in memory until restart.
  • A proposal names the next step for a person: the vendor's update command, or maia integration plan when a newer build is installed but not approved.
  • The update policy (off or notify, default notify) belongs to the active integration authorization. Changing it follows preview, confirmation, compare-and-set on the policy and authorization revisions, and a receipt naming the authenticated actor. integration apply carries it forward and integration remove ends it.
  • auto, adopting an update the vendor already installed, is Planned.

Unverified

Each adapter re-verifies its harness before it ships. Open points:

  • Cursor: MCP precedence, live reload, de-duplication across skills directories.
  • Codex: hook reload and local plugin enabling.
  • Claude Code: whether it follows symlinked skill directories.
  • Gemini, opencode and Copilot surfaces.

Imported from Maia OS docs at 3dc3e07