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)
);
- The DB is the source of truth.
secretvalues are encrypted with the connector key (connector_encryption_key_env); the API never returns them, only a masked status.- Each record is appended to
logs/settings_audit.jsonl.
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
enforce: advisory(default) — the dialog merely suggests; consumers work with the default/missing value.enforce: block— when the setting is unset, the consumer returns a structural error "setting required". Callers (chat, CLI, widget) turn it into a request, so the setting is requested at the point of use.verifyis optional and configured per setting:
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:
- Module relevance. If no configured agent
(
config/agents/**) references any of the module'skindtools (for example, no one usesgoogle_tool), that module's settings are markedrelevant: false. They remain on/settingsin a collapsed section "Not used by any agent", but are not shown in the dialog after login. A filled-in setting (a value in the DB orenv) keeps the module relevant, so a configured integration is always visible. - Additional (per setting). An optional (
required: false) setting without a value is markedadvanced: trueand moves to a collapsed section "Additional". As soon as a value is set, it returns to the main list.
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
- The
githubmodule declaresgithub/token(secret,global,required,env: GITHUB_TOKEN,trigger: on_login). - At startup, the host sees a declaration without a value → pending.
- The admin logs in; the shell shows the dialog.
- The value is saved to
settings.db, projected intoGITHUB_TOKENin the process environment, and written to the audit log. - The tool with
token_env: GITHUB_TOKENworks from the next call, without a restart. - 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
- Global — model, registry, DB,
/v1/settings*, admin dialog and page. - Principal and Agent scopes, filtering by auth scope.
- Local_user scope (encryption) and self-service in the widget.
- 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.