Appearance
Connection config (gaia.config.js)
GAIA splits configuration into two files with different jobs:
gaia.config.js— the connection config (this page). A small, dropsh-shaped{ site, plugins }file: which control plane do I talk to, and with which credentials.conductor.config.js— the engine config. The run machinery: remote, executor, agent, workspace, project, states,machine_id.
Both live in a .gaia/ directory — a project one (./.gaia/) or the user-global one (~/.gaia/).
The connection config is read by gaia dropsh, gaia ui, and the conductor's authentication. It carries no engine settings. That separation is what lets gaia dropsh answer a JSON:API question by loading one tiny file, instead of constructing the whole plugin farm the run engine needs.
The files
| File | Tracked? | Role |
|---|---|---|
~/.gaia/gaia.config.js | user-global, never committed | The default connection every repo inherits. Seeded by gaia conductor init / gaia upgrade. Most machines need nothing else. |
./.gaia/gaia.config.js | gitignored, optional | Project connection override — opt-in. Create it only when this repo must reach a different control plane than the machine default. When present it genuinely overrides the machine context, base_url included. |
~/.gaia/machine.config.js | user-global, never committed (chmod 0600) | Machine context — machine_id, user_id, base_url, client_id, client_secret. This machine's identity and connection, shared by every project on it. The only place the OAuth secret lives. |
| the shipped fallback | inside the @gaia-ai/core install | Last resort. Derives the connection from the machine context and constructs a session OAuth2 profile plus the Markdown renderer, so a home-rooted gaia ui / gaia dropsh works with no hand-authored config at all. |
./.gaia/conductor.config.local.js | gitignored, optional | Per-developer override, never generated. The generated configs import it if present (base_url, oauth, auth.basic, jsonapi_prefix on the connection side; see the engine config for the engine keys it also feeds). |
The home connection is the default, the project one is the exception
gaia conductor init writes only the engine conductor.config.js into a repo and seeds the global ~/.gaia/gaia.config.js. It never authors a project-local connection config. A repo whose .gaia/ holds nothing but an engine config is the normal shape — its connection is inherited from home.
Resolution precedence
The connection config is resolved by one rule, in this order. The first leg that produces a file wins:
| # | Leg | Source label |
|---|---|---|
| 1 | An explicit path | explicit |
| 2 | Project ./.gaia/gaia.config.js, found by walking from the current directory root-ward | project |
| 3 | Home ~/.gaia/gaia.config.js, when it exists | home |
| 4 | The shipped fallback | fallback |
The walk-up in step 2 climbs to the filesystem root, so from any directory under $HOME it can reach ~/.gaia/gaia.config.js. When it does, the result is labelled home — it is the same file step 3 would have returned, and the label should not lie about it.
The explicit leg differs by command. gaia ui accepts --config, $GAIA_CONFIG, and $DROPSH_CONFIG (first non-empty wins) and feeds that as the explicit path. gaia dropsh reads none of them — it always resolves through legs 2 to 4. Same rule, one extra input on the gaia ui side.
To see which file won without launching anything:
sh
gaia ui --print-configIt prints the resolved path, which leg produced it, the fallback and legacy flags, base_url, the current directory, and the detected project — then exits.
The legacy read is gated on content, not on existence
Before the split, a single conductor.config.js carried both halves. For back-compatibility, the walk-up in step 2 still accepts a conductor.config.js as a connection source — but only when its source really declares a top-level site.
That gate matters. gaia upgrade deliberately strips site and plugins out of every engine config, and an engine-only leftover file must not shadow the home connection every repo is supposed to inherit. An existence-only check would let it, and the load would then fail with requires site.base_url despite a perfectly good ~/.gaia/gaia.config.js sitting right there.
The check is a source read, never an import — a config may pull in the secret-bearing machine context, and path resolution must never execute it. A naive regex would not do either: it fires on the word site in a comment, and on a nested site: inside an addon's with: ... options.
Shape
js
// .gaia/gaia.config.js — or ~/.gaia/gaia.config.js
export default {
site: {
base_url: 'https://gaia.example.test',
jsonapi_prefix: '/jsonapi', // optional; this is the default
},
addons: [
{
use: '@dropsh/plugin-oauth2',
with: {
id: 'gaia',
default: true,
type: 'oauth2_client_credentials',
client_id: 'gaia-agent',
client_secret: process.env.GAIA_CLIENT_SECRET,
token_url: 'https://gaia.example.test/oauth/token',
scope: 'gaia:session',
},
},
'@gaia-ai/addon-essentials',
],
};Exactly one
@dropsh/plugin-oauth2entry, on exactly one scope. A connection declares ONE identity (GAIA-391). It used to declare two providers that differed only by the scope they requested, and every write had to pick one — that choice is what went away. Theidnames the connection, not a capability, which is why it isgaiaand not a job title.The
scopeis required, and it is the identity's capability ceiling. Omitting it does not mean "unrestricted". For aclient_credentialstoken — which every GAIA machine identity is — Drupal'ssimple_oauthreplaces the account's permissions with exactly the scope's, so a connection naming no scope either fails at token mint (invalid_request, hintCheck the scope parameter) or silently inherits whatever default scopes the OAuth consumer happens to carry.gaia:sessionis that one scope; what it grants is decided on the server, and which rows the identity then reaches is decided by the ranks it holds in a workspace.Upgrading an older config:
gaia upgrademigratesschema_version: 2to3in place (it backs the old file up and preserves every operator-set value). Run it with or before the deploy: an un-migrated config asks for the retiredgaia:project_managerand fails loudly withinvalid_scopeat token mint, whereas a token already issued keeps working.site.base_urlis required. A connection that resolves an empty one fails with an error naming the resolved path and the file that was expected to supply it — never an opaque downstream plugin error.site.jsonapi_prefixdefaults to/jsonapi.addons: []is the current form; each entry names a package whose./presetexport self-declares the connection plugin it contributes.
Config files are real ES modules, so they can compute their values — read an environment variable, import a machine context, or import an optional local override:
js
async function loadLocal() {
try {
return (await import('./conductor.config.local.js')).default ?? {};
} catch {}
return {};
}
const local = await loadLocal();
export default {
site: { base_url: local.base_url, jsonapi_prefix: '/jsonapi' },
addons: [
...(local.oauth
? [
{
use: '@dropsh/plugin-oauth2',
with: { scope: 'gaia:session', ...local.oauth, id: 'gaia', default: true },
},
]
: local.auth?.basic
? [{ use: '@gaia-ai/addon-auth-basic', with: { basic: local.auth.basic } }]
: []),
'@gaia-ai/addon-essentials',
],
};Connection addons
An addon is a package; a plugin is the single typed contribution it makes to one surface. One addon may ship several plugins. The connection surface's accumulator is connectionPlugins, and these are its members:
| Addon | Contributes |
|---|---|
@dropsh/plugin-oauth2 | An OAuth2 auth profile. with is the profile. |
@gaia-ai/addon-auth-basic | Inline HTTP Basic auth, given with: { basic: '<base64>' }. The fallback when OAuth2 is not available. |
@gaia-ai/addon-essentials | A meta-addon: the Markdown renderer (--format md) plus the authoritative JSON:API schema. |
@gaia-ai/addon-gaia-ui | The TUI renderer — the connection addon gaia ui loads dynamically. |
ui vs gaia-ui — different tiers, similar names
@gaia-ai/ui is the cockpit command app, the gaia ui command itself. @gaia-ai/addon-gaia-ui is the connection addon it loads to render. The name overlap is inherited from an earlier layout; a rename of the renderer is deferred to a future breaking release.
Entry forms
js
addons: [
'@gaia-ai/addon-essentials', // bare package name
{ use: '@gaia-ai/addon-auth-basic', with: { basic } }, // + construction options
]- The same package with different
withis a legitimate multi-instance, and only an exact repeat is deduplicated. Since GAIA-391 this is no longer illustrated with two@dropsh/plugin-oauth2entries: a GAIA connection declares exactly one,id: 'gaia'on scopegaia:session. - A meta-addon may compose others through its own
addonsarray; children apply depth-first, before the parent. - Configs name addons by their real package name. Never name
@gaia-ai/gaia/pluginsor@gaia-ai/core/builtins— the latter no longer exists, and the former is a back-compat barrel a config should not point at.
Authenticating
An OAuth2 provider still needs a token — ONE login, because a connection declares one provider (GAIA-391):
sh
gaia dropsh auth login --provider gaiaThe legacy plugins: [] array
The pre-addon form still loads unchanged, and can be mixed with addons: [] (legacy entries first, discovered ones after):
js
plugins: [
{ plugin: '@dropsh/plugin-oauth2', export: 'oauth2Plugin', with: local.oauth },
{ plugin: '@gaia-ai/addon-essentials' },
],Each entry is a { plugin, with, export? } descriptor. plugin names a real npm package, resolved ESLint-style (the config's own directory first, then the host's bases). with is the factory's options.
export is normally unnecessary: every @gaia-ai/addon-* package default-exports its single factory, so the loader auto-picks it (export → module default → sole exported function). @dropsh/plugin-oauth2 is the exception that needs export: 'oauth2Plugin', because plugins[] is also consumed by dropsh itself, whose resolver is export ?? 'default' with no sole-function fallback — and oauth2 has no default export.
The machine context
The machine context names who and which host, and carries the connection:
js
// ~/.gaia/machine.config.js (chmod 0600, never committed)
export default {
machine_id: 'workstation',
user_id: 'ada',
base_url: 'https://gaia.example.test',
client_id: 'gaia-agent',
client_secret: 's3cr3t…',
};machine_id— the real host identity, derived from the hostname at init (or--machine-id).user_id— your short user id (--user-id, or a prompt).base_url/client_id/client_secret— the control-plane connection.
Every project on the machine shares this one file. That is why a fresh checkout or worktree needs no per-project secret or connection wiring: the configs read it all from here. The engine config also composes its machine_id from it.
The secret
The OAuth client_secret lives only in the machine context — gitignored, user-only chmod 0600, never in a committed config. gaia conductor init resolves it once, when the context does not yet have one, from --secret-env <VAR> (it reads the value out of that environment variable) or a hidden prompt, then stores it. Once the context carries a secret, later init runs never touch it and need no secret input.
Rotating a secret means changing it on the control plane and updating ~/.gaia/machine.config.js on the affected machine. Nothing re-derives or regenerates it.
Upgrading from a pre-split install
An older layout kept the machine context outside ~/.gaia/. That location is still read when the canonical path is absent, so an un-migrated install keeps working — but the canonical path is ~/.gaia/machine.config.js, and gaia upgrade relocates it there, leaving a re-export shim behind so an un-upgraded config still resolves.
Migrating with gaia upgrade
Run gaia upgrade once after every install or upgrade. It is idempotent, and a no-op when there is nothing to do:
sh
gaia upgrade # migrate this install
gaia upgrade --dry-run # print the plan; write, move, chmod, and back up nothingIt opens with an intro naming the installed version, the resolved install path and whether that is a global install or a development clone, the config schema version it is migrating from and to, and the .gaia/ directories it will touch. Then, on the connection side, it:
- Decides about the project override — default no. The global
~/.gaia/gaia.config.jsis the default connection.gaia upgradenever creates or deletes a project override silently: on an interactive terminal it asks "remove this project connection override? [y/N]" when one exists (removing it with a.removed.bakbackup only on an explicit yes) or "create a project connection override for this repo? [y/N]" when none does. Non-interactive or unanswered means keep or skip. - Moves the machine context to
~/.gaia/machine.config.js(copy,chmod 0600, leaving a re-export shim so an un-upgraded config still resolves). The secret-bearing context is never regenerated or reshaped — only relocated. - Seeds or migrates the home connection config
~/.gaia/gaia.config.js. - Rewrites addon package names in both the project and home configs — see the engine config's migration section, which also covers what
upgradestrips out of an engine config.
gaia upgrade also runs automatically as the last step of gaia conductor init, so a fresh setup is already fully migrated.
The connection config is schema-versioned
Every generated gaia.config.js carries a header comment // @gaia-schema-version <N> and a schema_version: <N> field. upgrade routes on that marker, which makes it a real migrate-to-current-shape tool, decoupled from the npm package version — the schema version bumps only when the config shape changes:
| Existing marker | Action |
|---|---|
| absent (no file) | seed the current template |
== CURRENT | kept, byte-identical |
1..CURRENT-1 (versioned, stale) | migrated through the per-version chain, backing up <file>.v<old>.bak |
0 (present but unversioned / hand-authored) | kept — hand edits are never clobbered |
> CURRENT (newer than this CLI) | kept — it refuses to downgrade a config newer than the code |
Only the generated, gitignored connection config is schema-migrated. The committed engine conductor.config.js and the machine context are outside the migrate path.
update vs upgrade — apt-style, in this order
update gets newer code: npm install -g @gaia-ai/gaia@latest. There is no gaia update command, because the CLI is an ordinary global npm package. gaia upgrade then migrates your config to the shape the new code expects. The start-up "new version available" notice names both steps.
Next
- Engine config (
conductor.config.js) — the other half: slots, addons,machine_id, and multiple conductors per repo. - Set up a project — the end-to-end onboarding these files come out of.
gaia ui— the cockpit that reads this config.