Skip to content

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

FileTracked?Role
~/.gaia/gaia.config.jsuser-global, never committedThe default connection every repo inherits. Seeded by gaia conductor init / gaia upgrade. Most machines need nothing else.
./.gaia/gaia.config.jsgitignored, optionalProject 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.jsuser-global, never committed (chmod 0600)Machine contextmachine_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 fallbackinside the @gaia-ai/core installLast 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.jsgitignored, optionalPer-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:

#LegSource label
1An explicit pathexplicit
2Project ./.gaia/gaia.config.js, found by walking from the current directory root-wardproject
3Home ~/.gaia/gaia.config.js, when it existshome
4The shipped fallbackfallback

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-config

It 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-oauth2 entry, 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. The id names the connection, not a capability, which is why it is gaia and not a job title.

  • The scope is required, and it is the identity's capability ceiling. Omitting it does not mean "unrestricted". For a client_credentials token — which every GAIA machine identity is — Drupal's simple_oauth replaces the account's permissions with exactly the scope's, so a connection naming no scope either fails at token mint (invalid_request, hint Check the scope parameter) or silently inherits whatever default scopes the OAuth consumer happens to carry. gaia:session is 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 upgrade migrates schema_version: 2 to 3 in 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 retired gaia:project_manager and fails loudly with invalid_scope at token mint, whereas a token already issued keeps working.

  • site.base_url is 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_prefix defaults to /jsonapi.

  • addons: [] is the current form; each entry names a package whose ./preset export 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:

AddonContributes
@dropsh/plugin-oauth2An OAuth2 auth profile. with is the profile.
@gaia-ai/addon-auth-basicInline HTTP Basic auth, given with: { basic: '<base64>' }. The fallback when OAuth2 is not available.
@gaia-ai/addon-essentialsA meta-addon: the Markdown renderer (--format md) plus the authoritative JSON:API schema.
@gaia-ai/addon-gaia-uiThe 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 with is a legitimate multi-instance, and only an exact repeat is deduplicated. Since GAIA-391 this is no longer illustrated with two @dropsh/plugin-oauth2 entries: a GAIA connection declares exactly one, id: 'gaia' on scope gaia:session.
  • A meta-addon may compose others through its own addons array; children apply depth-first, before the parent.
  • Configs name addons by their real package name. Never name @gaia-ai/gaia/plugins or @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 gaia

The 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 nothing

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

  1. Decides about the project override — default no. The global ~/.gaia/gaia.config.js is the default connection. gaia upgrade never 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.bak backup 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.
  2. 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.
  3. Seeds or migrates the home connection config ~/.gaia/gaia.config.js.
  4. Rewrites addon package names in both the project and home configs — see the engine config's migration section, which also covers what upgrade strips 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 markerAction
absent (no file)seed the current template
== CURRENTkept, 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