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 theskillsCLI'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-orchestrationSkill) reach only the user's plugin. Worker-only items (themaia-workerSkill) 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.
| Operation | Kind | Purpose |
|---|---|---|
config.resolve | read | The effective configuration for a harness, scope, Project and Node |
config.plan | read | The exact changes apply would make, per harness and file, with the reason for each and anything that cannot be represented |
config.apply | mutation | Preview, confirmation, compare-and-set, receipt; backs up before any overwrite |
config.drift | read | Managed items whose content changed since Maia wrote them, and Nodes whose applied digest differs from the desired one |
config.import | mutation | Adoption: 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:
- 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.
- 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). - 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. - Marked blocks in single-file instructions, only when nothing above works
(
<!-- maia:begin <id> -->…<!-- maia:end <id> -->).
| Surface | Claude Code | Codex | Pi | Cursor |
|---|---|---|---|---|
| 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, skills | app-server skills/extraRoots/set | --skill; SDK additionalSkillPaths | --plugin-dir |
| Instructions | CLAUDE.md, .claude/rules/*.md (@ imports) | AGENTS.md per directory, 32 KiB cap | AGENTS.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/*.md | agents/*.toml | none (prompt templates) | .cursor/agents/*.md |
| Hooks | settings hooks | hooks.json (needs trust) | none (extensions) | hooks.json (camelCase events) |
| Permissions | permissions.allow/ask/deny | approval and sandbox policy, rules/*.rules | none | CLI permissions.allow/deny |
| Home override | CLAUDE_CONFIG_DIR | CODEX_HOME | PI_CODING_AGENT_DIR | none 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'sgit statusstays 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 withskills.include_instructions=false, which drops the Skills list from its prompt (decision). This replaces anydeveloper_instructionsfrom 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 detectchecks that the installed Codex still honours the prompt keys (see the CLI reference). - Claude Code: the Node writes a plugin (
.claude-plugin/plugin.jsonnamedmaia, plusskills/maia-worker/SKILL.md) in its private state, one folder per Skill digest, and passes it asplugins: [{ path, skipMcpDiscovery: true }](--plugin-dir-no-mcp, the one plugin flag the spawn check admits, for that exact folder) withskills: ["maia:maia-worker"]and theSkilltool, which the PreToolUse guard limits to that Skill. The plugin follows the worktree's outcome, sosession showstays 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 realclaudethat loaded it wrote only its usual Session state in itsCLAUDE_CONFIG_DIR, no plugin or Skill. - Nothing reaches
~/.claude,~/.codex,~/.agentsor a Maia-managed account root. .maia/worktree.jsonwith"workerSkills": falseturns the Skill off for the Project's new coding Sessions (read at creation, likecopyandsetup).session show(and MCPsession_show) lists what the latest launch injected inworktree.skillsas{ 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 fromCLAUDE_CONFIG_DIR/skillsor.claude/skills; it lists them only with theuserandprojectsetting sources, which would also load settings, hooks andCLAUDE.md. A plugin passed at launch loads, and theSkilltool appears only whentoolsnames it. - Codex 0.160 lists Skills from
$CODEX_HOME/skillsand from the repository's.agents/skills, but tells the model to openSKILL.mditself. Undermaia-writethe 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
skillsCLI is not run at launch. Version 1.7.0 (npm integritysha512-OfePnDft+Xt9/tCoHdCUe5fkM8i+Q3QOSQO53hm7mKtsXyvc+CKOAAliVWZ484HS3cWx+6r+ob0AArixs3jYXw==) writes.agents/skills/<name>for Codex and.claude/skills/<name>for Claude withadd --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 throughnpx, writesskills-lock.jsoninto the worktree and~/.agents/.skill-lock.jsoninto the home folder, and its Claude target is not read by workers. It stays the installer forconfig.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
maiaon its absolute PATH; a remote Node's unit PATH starts with its release'sbin/, whosemaiaruns 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 danglingmaialink counts as absent; a danglingnodelink 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 amaiaornodethat 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/binthat 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 resolvedmaiaare logged as a warning. A fixed/bin/shcommand 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 withmcp --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-existingmaia_workerentry refuses launch. Startup notifications admit that exact name alone. - Claude gets SDK
mcpServerswithcommandandargs,strictMcpConfig: true, and exactmcp__maia_worker__<tool>names inallowedToolsand the PreToolUse guard.settingSources: []and the Skill plugin'sskipMcpDiscoverystay 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.
--workerrefuses missing Session variables; Node auth refuses expiry and revocation on every operation. Worker tools derive from the existing role table, includingsession_listrestricted to the credential's Project, andsession_message_send,session_inbox_checkandsession_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
- Manifest. Per machine: harness, scope, path, kind (file, directory, entry, block), key, source revision and the digest of what Maia wrote.
- Compare-and-set. Write only when the target is absent or still has the digest Maia wrote; re-read just before the atomic rename.
- Never delete what Maia did not write. Remove a managed item only while it is unchanged.
- Harness-owned keys stay untouched: Claude's local approvals, Codex's
[hooks.state], project trust and tool approvals, Pi's/mcptoggles. They are overlays, not drift. Maia never writes a trust hash. - Drift goes through preview and an explicit choice: adopt into
.maia(a Skill into theskillsCLI's lock), overwrite after a backup, detach, or skip. - One Skill name everywhere, recorded in the manifest and
metadata.maia-id. - Copies, not symlinks, by default (harness edit tools, Windows and approval prompts break on symlinks); directories are staged and renamed into place.
- Merge, never replace. Edit keyed entries; never rewrite a whole MCP map
or hooks list, and never leave
.bakcopies beside harness files. Each adapter ships recorded fixtures of its harness's files and a--checkmode.
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 detectreports installation and approval, whether the approved build launches, and freshness as separate facts, each allowedunknown. The Node adds them only when the request asks (upkeep: true), so an older client keeps decoding detection.connection detect --refreshasks 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 planwhen a newer build is installed but not approved. - The update policy (
offornotify, defaultnotify) 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 applycarries it forward andintegration removeends 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