Appearance
Engine config (conductor.config.js)
conductor.config.js is the engine half of GAIA's split config model: the run machinery a conductor needs to claim tickets and dispatch agents. It lives in a repo's .gaia/ directory and is committed.
No site, no plugins
An engine config carries no connection material — no site, no auth plugins. Those live in the sibling connection config gaia.config.js, and the conductor inherits its connection from the global ~/.gaia/gaia.config.js unless the repo has an opt-in project override.
If an engine config still declares site or plugins, the loader emits exactly one actionable warning naming the file and the offending keys, and ignores the values — they are never merged. gaia upgrade strips them out for you.
The files
| File | Tracked? | Role |
|---|---|---|
./.gaia/conductor.config.js | committed | The default conductor's engine config. Bakes project (the one genuinely per-repo value), composes machine_id from the machine context, and wires the run machinery through addons: []. Also carries the optional states, label, prompt, scheduler settings, and hooks. |
./.gaia/<variant>.conductor.config.js | committed | A sibling conductor, bound to its own project. See multiple conductors per repo. |
~/.gaia/machine.config.js | user-global, never committed (chmod 0600) | Machine context — machine_id, user_id, base_url, client_id, client_secret. Every engine config on the machine composes its machine_id from it. Documented on the connection config page. |
./.gaia/conductor.config.local.js | gitignored, optional | Per-developer override, loaded only if present and never generated. Create it by hand to override any field — pin a literal machine_id, change the agent model, point a variant elsewhere. |
gaia conductor init writes the committed engine config and is non-destructive: it creates it only if missing, and leaves an existing one untouched unless you pass --force. It never authors a project connection config. For the full onboarding sequence, see Set up a project.
Shape
A config file is a real ES module, so it can compute its values. This is the shape gaia conductor init scaffolds: read the user-global machine context, read the optional local override, compose machine_id, and wire the run machinery through addons: [].
js
// .gaia/conductor.config.js — committed
// The user-global machine context: identity + connection (incl. the secret),
// shared by every project on this machine. Never committed.
async function loadMachine() {
try {
return (await import(`${process.env.HOME}/.gaia/machine.config.js`)).default ?? {};
} catch {}
return {};
}
// OPTIONAL per-developer override, loaded only if present. Never generated.
async function loadLocal() {
try {
return (await import('./conductor.config.local.js')).default ?? {};
} catch {}
return {};
}
const machine = await loadMachine();
const local = await loadLocal();
const project = local.project ?? 'my-project';
export default {
project,
machine_id:
local.machine_id ??
(machine.user_id && machine.machine_id
? `${machine.user_id}-${machine.machine_id}-${project}`
: undefined),
states: ['spec', 'diagnose', 'coding', 'review'],
max_parallel: 5,
// Lifecycle hooks are executor-owned and live at the config TOP LEVEL —
// not on a plugin descriptor's `with.hooks`.
hooks: { after_create: './scripts/setup-worktree.sh' },
addons: [
'@gaia-ai/addon-remote-drupal',
'@gaia-ai/addon-herdr',
{ use: '@gaia-ai/addon-codex', with: { model: local.model ?? 'gpt-5' } },
],
};| Field | Required | Default | Meaning |
|---|---|---|---|
project | yes | — | The GAIA project name this conductor serves. The only genuinely per-repo value. |
machine_id | yes | — | This conductor's stable node identity. No derived fallback — see machine_id composition. |
states | no | [] | The workflow states this conductor serves. Empty or absent means every claimable state in the project; a non-empty list narrows it to those states. |
addons | see below | [] | Self-declaring addon entries that fill the four slots. |
remote / executor / agent / workspace | see below | — | The explicit per-slot descriptor form. |
label | no | the machine_id | Human-readable conductor label. |
prompt | no | built-in | The agent prompt template — the GAIA run contract, not project workflow. It bounds the agent to exactly one state. Placeholders: {identifier}, {state}, {runUuid}. |
max_parallel | no | 1 | Concurrent runs. |
poll_interval_ms | no | 5000 | Claim poll interval. |
lease_seconds | no | 300 | Claim lease length. |
hooks | no | — | Shell commands run at workspace and run boundaries: after_create, before_run, after_run, after_done. Top-level, see below. |
Hooks
Lifecycle hooks are executor-owned and belong at the config top level, not on a plugin descriptor. The executor invokes each one, logs loudly and continues on failure, and never wedges a run over a failing hook. An addon's with.hooks is also accepted, but a top-level hooks wins over it — so keep them top-level and there is nothing to reason about.
js
hooks: {
after_create: './scripts/setup-worktree.sh', // a fresh worktree was created
before_run: './scripts/warm-caches.sh', // before dispatching an agent
after_run: './scripts/collect-artifacts.sh',
after_done: './scripts/reclaim-env.sh', // ticket done, before teardown
},Every slot must resolve
The config must resolve a remote, an executor, a workspace, and at least one agent — whether from addons: [], from explicit slots, or a mix. Each missing one fails to load with an error naming the slot and both ways to fill it.
Addons
An addon is a package. It ships a fixed ./preset export that self-declares — through named, per-surface accumulator functions — what it contributes. The loader discovers and registers those contributions from one addons: [] list, so a conforming addon needs no per-slot wiring.
A plugin is the single typed per-surface contribution. One addon may ship several: @gaia-ai/addon-herdr ships an executor and a workspace, from one entry.
The conductor surface's accumulators are remotes, executors, workspaces, and agents. These are its members:
| Addon | Contributes |
|---|---|
@gaia-ai/addon-remote-drupal | The Drupal control-plane remote — the sole remote in a normal engine config. |
@gaia-ai/addon-herdr | An executor and a workspace, from a single entry — the pairing is encoded in its preset. |
@gaia-ai/addon-workspace-git | A git workspace (worktree management, instruction loading). |
@gaia-ai/addon-claude | The Claude agent. Parses a real run footprint. |
@gaia-ai/addon-codex | The Codex agent. |
@gaia-ai/addon-pi | The pi agent. Parses a real run footprint. |
@gaia-ai/addon-kimi | The Kimi agent. Footprint still stubbed. |
@gaia-ai/addon-opencode | The OpenCode agent. Footprint still stubbed. |
Each config file carries the addons: [] for its own surface. An engine config lists conductor addons; a connection config lists connection addons. The loader runs only that surface's accumulators — which is why gaia dropsh reads the small connection file and never builds the engine.
Entry forms
js
addons: [
'@gaia-ai/addon-remote-drupal', // bare package name
{ use: '@gaia-ai/addon-claude', with: { model: 'opus' } }, // + construction options
{ use: '@gaia-ai/addon-codex', priority: (t) => … }, // agents: + a priority
]- Singleton slots — remote, executor, workspace — resolve last-wins across the list: a later addon overrides an earlier one, and a warning fires when more than one competes.
- Agents accumulate into a candidate list;
prioritypicks one per ticket (below). - The same package with different
withis a legitimate multi-instance. Only an exact repeat is deduplicated. - A meta-addon may compose others through its own
addonsarray; children apply depth-first, before the parent. - Name real packages. Never
@gaia-ai/gaia/plugins(a back-compat barrel a config should not point at) or@gaia-ai/core/builtins(deleted).
herdr is listed once
@gaia-ai/addon-herdr contributes executor + workspace from a single entry — the pairing is encoded in its preset. The old export: 'herdrWorkspace' wiring is not needed in the addons: [] form.
Agent footprints
Each agent addon owns its own transcript parser, so the stateless finalize step can report real effort metrics — tokens, duration, agent turns, tool calls, user prompts, model. claude and pi parse a real footprint. kimi and opencode still stub it (an empty log yields all zeroes) until their parsers land. See Ticket metrics for what those numbers become.
The explicit slot form
The pre-addon per-slot form still loads unchanged, and an explicit slot wins over a discovered addon of the same kind. It is worth knowing because it is how you override one piece of an otherwise addon-driven config:
js
remote: { plugin: '@gaia-ai/addon-remote-drupal' },
executor: { plugin: '@gaia-ai/addon-herdr' },
workspace: { plugin: '@gaia-ai/addon-herdr', export: 'herdrWorkspace' },
agent: { plugin: '@gaia-ai/addon-codex', with: { model: 'gpt-5' } },Every slot is a { plugin, with, export? } descriptor, not an import:
pluginnames a real npm package — a genuine runtime dependency, resolved ESLint-style (the config's own directory → the current directory → thegaiainstall). There is no bundle: npm resolves the dependency tree normally, and the exact same descriptor works in a development checkout and on a global install.withis the factory's construction options.exportis normally unnecessary. Every@gaia-ai/addon-*package default-exports its single factory, so the loader auto-picks it (export→ module default → sole exported function).The
workspaceslot on a multi-plugin addon is the exception.@gaia-ai/addon-herdrdefault-exports its executor, so its workspace must be named:{ plugin: '@gaia-ai/addon-herdr', export: 'herdrWorkspace' }. Prefer theaddons: []form, where this disappears.
The agent slot may be an array
Besides a single descriptor, agent accepts an array of candidates, each optionally carrying a pure config-side priority(ticket) method. At dispatch the conductor evaluates every candidate's priority(ticket), sorts descending, and runs the winner — so config picks the agent per ticket, with no policy in the conductor:
js
// single descriptor — unchanged, backward compatible
agent: { plugin: '@gaia-ai/addon-claude', with: { model: 'opus' } },
// array — per-ticket selection by static assessment
agent: [
{ agent: { plugin: '@gaia-ai/addon-claude', with: { model: 'opus' } },
priority: (t) => t.labels.includes('infra') ? 10
: t.environments.some((e) => e.tier === 'prod') ? 5 : 0 },
{ plugin: '@gaia-ai/addon-codex' }, // bare descriptor, no priority → lowest
],- An array entry is either a bare descriptor or a
{ agent, priority? }wrapper (whose inneragentis itself a descriptor). priority(ticket)receives the dispatched ticket enriched withlabels: string[](label term names) andenvironments: { name, tier }[], sideloaded by the conductor. The function never fetches anything.- A missing
priorityscores strictly lowest; ties resolve by config array order (stable sort), so a mixed or all-default array still resolves deterministically. - This is agent-selection priority, config-side. It is distinct from the ticket's own
priorityfield, which orders claim candidates. The two do not interact. No server field drives selection; the chosen agent id is persisted on the run only so the stateless finalize step routes footprint parsing to the agent that actually ran.
machine_id composition
machine_id is the conductor's single source of identity. The committed config composes it from the machine context:
machine_id = `${machine.user_id}-${machine.machine_id}-${project}` // e.g. ada-workstation-my-projectUnique per project, tied to the real machine and the real user — two developers on the same host and project never collide. Because every project on a machine shares one context, a new checkout or worktree needs no per-project secret or connection wiring.
machine_id is required, and there is no derived fallback. A config that resolves an empty or absent one fails to load with requires non-empty machine_id. The id is deliberately explicit rather than something that could silently diverge between a composed value and a path hash. A bare checkout with no machine context must set it another way: onboard the context, pass --machine-id, or write a literal into the config.
With several configs in one .gaia/, each composes its own from its own project:, so co-located conductors are distinct by construction — no filename-based hashing is involved.
Multiple conductors per repo
One git repository can bind to several GAIA projects. The sharpest case is a multisite codebase, where one repo serves several sites, each a distinct product deserving its own project, tickets, conductor, and board. A .gaia/ directory may therefore hold N sibling engine configs, each a complete config bound to its own project:.
Naming. The default is the file named exactly conductor.config.js. Variants are <variant>.conductor.config.js. A file is a conductor config iff it is exactly conductor.config.js or ends with .conductor.config.js — so an unrelated vite.config.js and the near-miss myconductor.config.js are both ignored.
.gaia/
conductor.config.js # gaia conductor poll → the default
shop.conductor.config.js # gaia --conductor shop conductor poll
intra.conductor.config.js # GAIA_CONDUCTOR=intra gaia conductor pollSelection is per invocation, by conductor name — which is the config file's stem: --conductor <name> or $GAIA_CONDUCTOR, with the flag winning over the environment. --conductor conductor selects the default explicitly; --conductor shop resolves shop.conductor.config.js.
Discovery is synchronous and filename-only — no candidate config is ever imported to decide which one to select:
- An explicit
--config/$GAIA_CONDUCTOR_CONFIGfull path wins verbatim, with no walk. - Otherwise walk root-ward to the nearest
.gaia/directory holding a conductor config, so any subdirectory of a project resolves the same directory. - A selector resolves that stem's file, or errors listing the available stems.
- With no selector:
conductor.config.jspresent → it is the default. Else exactly one config → use it (back-compat, no selector needed). Else → an error naming the stems and the selector. - No
.gaia/config anywhere up the tree → an actionable setup error.
No ambiguous default is possible: two files named conductor.config.js cannot coexist in one directory, so "more than one default marked" is structurally impossible.
No collision: each config carries its own required machine_id, composed from its own project: — so each is a distinct conductor entity with its own claims and its own workspace. A single-config repo resolves the same file and the same machine_id as it always did; only adding a second config changes any behaviour.
For a multisite repo: one .gaia/<site>.conductor.config.js per site, each with its own project: and workspace slot, and dispatch with --conductor <site>. If a site also needs a different control plane, that is the one case for an opt-in project connection override.
Migrating an existing install
gaia upgrade is the migration path. It is idempotent, a no-op when there is nothing to do, and --dry-run prints the plan while writing nothing:
sh
gaia upgrade
gaia upgrade --dry-runOn the engine side it does two things.
It strips legacy connection and auth material out of every engine config. For each engine config in the .gaia/ — the supported naming rule only, so an unrelated vite.config.js is never touched — it removes site, the auth plugins, and the now-orphaned connection preamble constants, backing up <file>.legacy.bak. What stays is engine-only: remote, executor, agent, workspace, hooks, project, machine_id, and the local-override loader.
It rewrites addon package names. Three rewrites, across the connection config and every engine config the loader recognises — the default plus each <variant>.conductor.config.js, in both bare-string and { use } / { plugin } descriptor forms:
- the old
@gaia-ai/plugin-*names →@gaia-ai/addon-*; - the deleted
@gaia-ai/core/builtins→@gaia-ai/addon-auth-basic(abasictoken, or a connectionplugins:entry) or@gaia-ai/addon-remote-drupal(an engineaddons:entry); - the host barrels → the addon that owns the descriptor's
export:—drupalRemote→@gaia-ai/addon-remote-drupal,gitWorkspace→@gaia-ai/addon-workspace-git,basicAuthProvider→@gaia-ai/addon-auth-basic— dropping the then-redundantexport:when the target default-exports that factory.
Matching is a closed set of known addon names and known barrel exports, inside a matched quote pair. So comments, formatting, an unknown @gaia-ai/plugin-something, an unmapped export: name, a barrel mention with no adjacent export: (where the target is simply unknowable), the *Plugin type identifiers, and the plugins: / addons: keys themselves are all left alone — nothing is retargeted at a guess.
A config already using addon-* names comes out byte-identical: no rewrite, no backup, no report line. A real rewrite leaves <file>.pre-addon-rename.bak. Not migrated: conductor.config.local.js.
For the connection-side steps — the project-override decision, the machine-context move, seeding the home connection, and schema versioning — see gaia upgrade on the connection config page.
Next
- Connection config (
gaia.config.js) — the other half:site, auth, the machine context. - Set up a project — the onboarding sequence that produces this file.
- How GAIA works — what the conductor does with these slots at run time.