Scoped settings and pending configuration

A setting is a value that must be set in a deployment for a module, agent, tool, or hook to work: an API key, an account name, an endpoint. Settings are declared by their owner; they are stored in the DB, optionally projected into process environment variables, and collected from a person via a non-blocking dialog in the admin panel.

This replaces the previous approach where each consumer invented its own resolution *_env, and the only step-by-step setup was the first-run wizard for the LLM (see Overlay surfaces). Onboarding stays as is: it is a blocking first-run wizard for the LLM only. Settings are a shared, always-available, and non-blocking mechanism inside the admin panel.

Concepts

Term Meaning
Declaration (SettingSpec) What the owner needs: id, title, kind, scope, requiredness, UI hints.
Key Canonical id {owner}/{id} (e.g. github/token, agent:greeter/greeting).
Owner The module or agent template that declared the setting.
Scope Who owns the value (see below).
Value The stored string for the tuple (key, scope, scope_id).
Pending A required setting without a value in the needed scope, or a runtime request from a module.

Scopes and resolution

Values live at four levels, from general to specific:

Scope Belongs to Who can change
global the entire deployment admins with full scope
principal the identity of an admin/operator (Principal.name) that principal / full admins
agent an agent template admins allowed for that template
local_user a canonical end user (id from the widget/login) the user themselves (self-service)

A consumer resolves a setting from the most specific to the most general:

local_user → principal → agent → global → declared env variable → edited config → default

scope declarations are the most specific supported level; more general levels are used as a fallback. Resolution mirrors get_resolved of the state store (user → session → agent).

Example: github/token is declared scope: global (one key per deployment). mycrm/password is declared scope: local_user — each user sets their own, and if they haven't, the global value is used (if any).

Declaring settings

Rust modules override Module::settings:

fn settings(&self) -> Vec<agent_os_modules::SettingSpec> {
    vec![agent_os_modules::SettingSpec {
        id: "token".into(),
        title: "GitHub token".into(),
        description: "Personal access token with `repo` scope.".into(),
        kind: SettingKind::Secret,
        scope: SettingScope::Global,
        required: true,
        ..Default::default()
    }]
}

DLL modules declare the same shape in module.yaml:

id: github
version: 1.0.0
settings:
  - id: token
    title: GitHub token
    kind: secret
    scope: global
    required: true
    env: GITHUB_TOKEN
    trigger: on_login

Fields

Field Values Meaning
id string Unique within the owner; the key is {owner}/{id}.
title / description string Labels in the dialog; description is markdown.
kind string secret bool int url select Widget + validation.
scope global principal agent local_user The most specific supported level.
required bool Affects the pending count.
default string The value shown when not set (not stored).
options list For kind: select.
env string The name of the process variable into which the value is projected.
trigger on_login on_use manual When the dialog shows it.
enforce advisory block block: the consumer fails with a structural error until it is set.
verify object Optional credential check (see below).

Modules without Module (declare_module!, DLL)

A module built via agent_os_sdk::declare_module! (dual-transport form, used by portable/DLL modules such as searchers) does not implement Module::settings(). It declares settings by contributing a data-item to the well-known registry agent_os_sdk::SETTINGS_DECLARATIONS ("settings.declarations"), which is owned by the always-on kernel:

fn contribute(h: &mut dyn agent_os_sdk::RegistryHost) {
    h.stable(agent_os_tool_api::TOOL_ENGINES, /* … */);
    h.data_item(
        agent_os_sdk::SETTINGS_DECLARATIONS,
        &serde_json::json!({
            "owner": "searchers",
            "spec": {
                "id": "firecrawl_api_key",
                "kind": "secret",
                "scope": "global",
                "required": true,
                "env": "FIRECRAWL_API_KEY",
                "trigger": "on_login"
            }
        })
        .to_string(),
    );
}

This is transport-neutral: the same contribute works both linked and in a DLL. The host merges these items with any from Module::settings().

Storage

Values live in a separate DuckDB, logging.settings_db_path (default logs/settings.db):

CREATE TABLE settings (
    owner       TEXT NOT NULL,
    setting_id  TEXT NOT NULL,
    scope_kind  TEXT NOT NULL,   -- global | principal | agent | local_user
    scope_id    TEXT NOT NULL,   -- "" для global
    value       TEXT NOT NULL,
    secret      BOOLEAN NOT NULL,
    updated_at  TIMESTAMP NOT NULL,
    updated_by  TEXT NOT NULL,
    PRIMARY KEY (owner, setting_id, scope_kind, scope_id)
);

Projection into the environment

When a declaration specifies env: NAME, the value is projected into the environment of the running process at startup and on every write (via the existing agent_os.Env mechanism). Therefore existing tools with *_env and scripts env_var(name) see the new value immediately — without a restart.

Priority (from general to specific): a manual value in .env / config/env.overrides.yaml wins over the projection from settings (an operator's emergency override). In the admin panel such a setting is marked as "overridden externally".

Enforcement and validation

verify:
  kind: http            # none | http | script
  method: GET
  url: "https://api.example.com/me"
  headers: { Authorization: "Bearer {value}" }
  expect_status: 200

{value} is substituted at validation time. The result is written to verified_at / verify_error and is shown in the dialog. Without verify, the value is saved without validation.

Ports and HTTP API

Modules access settings through the host port agent_os.Settings (sensitive, capability agent_os.Settings):

declarations()                 -> all specs (never values)
status(scope, scope_id)        -> declarations + resolved/pending
get(key, scope, scope_id)      -> resolved value ("" if not set)
set(key, value, scope, id, actor)
request(spec_json)             -> raise a runtime request

The HTTP surface is owned by the feature module settings:

Route Access Purpose
GET /v1/settings admin declarations + caller status (filtered by auth scope)
GET /v1/settings/pending admin unset required + runtime requests
POST /v1/settings admin { key, value, scope, scope_id }
POST /v1/settings/request admin raise a runtime request
GET/POST /v1/settings/me user end-user self-service (local_user)

Admin UI

The settings module serves /settings and /ui/settings.js and adds a Settings item to the admin menu. After a successful login, the login page polls /v1/settings/pending and shows a dismissible dialog; dismissal is remembered for the browser session, and the prompt returns on the next login. Nothing blocks navigation: /settings is also available directly.

Settings relevance

The page and the dialog prioritize what is actually used:

GET /v1/settings returns both flags; GET /v1/settings/pending filters by relevant != false, so unused integrations do not "nag" at login. The mapping of kind → модуль comes from the tool.engines registry; the tool kinds in use come from the agents' tool configs.

Example: GitHub token

  1. The github module declares github/token (secret, global, required, env: GITHUB_TOKEN, trigger: on_login).
  2. At startup, the host sees a declaration without a value → pending.
  3. The admin logs in; the shell shows the dialog.
  4. The value is saved to settings.db, projected into GITHUB_TOKEN in the process environment, and written to the audit log.
  5. The tool with token_env: GITHUB_TOKEN works from the next call, without a restart.
  6. The next login does not show the prompt.

First consumers

The google and yandex modules declare oauth_client_id / oauth_client_secret (global secrets, projected into GOOGLE_OAUTH_CLIENT_SECRET / YANDEX_OAUTH_CLIENT_SECRET). The tool configs already read these variables, so a deployment where they are set in .env sees no prompts; a fresh deployment will ask once at admin login. Since they are resolved from the environment, they do not nag when the key is set.

The searchers module declares firecrawl_api_key (via the registry channel above — it is macro-generated), is projected into FIRECRAWL_API_KEY with trigger: on_login — visible in the dialog after login and on the settings page, and the value is projected into the process without a restart.

Implementation phases

  1. Global — model, registry, DB, /v1/settings*, admin dialog and page.
  2. Principal and Agent scopes, filtering by auth scope.
  3. Local_user scope (encryption) and self-service in the widget.
  4. Runtime requests, enforce/verify, select, settings declared by agents.

Relationship to onboarding

Onboarding (Surfaces-overlays) is a blocking first-run wizard for the LLM provider only. Settings are a separate, non-blocking, shared mechanism inside the admin panel. They do not override routes and cannot block an operator's access to configuration.