Configuration
SkillAI & modelsGuides your agent on where a configuration value belongs — config schema, config file, or environment variable.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Configuration skill
About this capability
Where a configuration value belongs — app config schema, agent-native.config.ts, or an environment variable — and how the layers resolve. Use before adding an env var, a configure*/set* function, a plugin option, or a register* function, and when deciding whether something is config or a registry.
What this skill tells your AI
The instructions your AI receives, as published by builderio/agent-native in .agents/skills/configuration/SKILL.md and read by ahel’s review.
Core had 301 distinct environment variables and only 48 of them were secrets.
The other 253 are product decisions that landed in process.env because at the
call site that needed them, nothing else was reachable. This skill exists so the
next value does not do the same thing.
The rule
Consumer code never reads process.env. Exactly four resolvers do:
| Resolver | Covers |
|---|---|
The app config env layer (app-config/env-layer.ts) | product behavior, via a declared .meta({ env }) alias |
resolveDeployEnvironment() | platform facts — NODE_ENV, NETLIFY, AWS_* |
readDeployCredentialEnv() | secrets, as the deployment layer inside scoped resolution |
getAmbientUserEmail() / getAmbientOrgId() | CLI identity when there is no request context |
The bottom three are not configuration: platform variables are facts nobody sets in an app, credentials resolve per user or org, and ambient identity exists only for CLI runs. Everything else is a field in the schema.
This is the credential rule ("one resolver per key, and every runtime path goes through it") applied to all configuration, with a schema lookup replacing the grep.
Where a value goes
| Value | Where |
|---|---|
| Server behavior, closures, per-tenant resolvers, anything not for a browser | defineAppConfig() |
| Client-visible data | agent-native.config.ts |
| A deployment override of a server value | an env alias on the schema field |
| A secret value | the vault, via resolveCredential — see the secrets skill |
| Something a user changes at runtime in the UI | the settings store, not config |
For a new non-secret value that describes public app behavior or exposed
capabilities, extend AgentNativeConfig and put the default in
agent-native.config.ts. Its supported paths have deterministic deployment
aliases such as runtime.auth.enabled →
AGENT_NATIVE_CONFIG_RUNTIME_AUTH_ENABLED. Whole-config, section, and deeply
nested object values use the same namespace and accept JSON strings. Use these
aliases for public values that need to vary by deployment. Do not create a new
standalone VITE_* or AGENT_NATIVE_* variable for a value that belongs in
this config surface.
Unknown keys inside fixed-shape JSON fragments must fail loudly; the
per-mode onboarding.firstRun map is the intentional dynamic-key exception.
Those aliases are still public. The resolved config is serialized into the
browser bundle, so credentials, provider keys, database URLs, and other secret
or user-scoped values never belong there. Keep server-only behavior in
defineAppConfig() and use its declared .meta({ env }) aliases when a
deployment override is needed.
agent-native.config.ts is serialized into the bundle and hard-cached in a
public SSR shell. Moving a value there to avoid threading it publishes a
deployment fact to every visitor. It also cannot carry a closure.
Adding a field
Add it to the domain file under packages/core/src/app-config/, or add a new
domain file plus one line in schema.ts.
// packages/core/src/app-config/email.ts
export const emailConfig = z.object({
brandColor: z.string().regex(HEX_COLOR).optional().meta({
env: "EMAIL_BRAND_COLOR",
doc: "Accent color for framework-rendered emails.",
}),
renderer: z.custom<EmailRenderer>().optional(),
});
Then read it: getAppConfig().email.brandColor.
An app sets it from server/plugins/config.ts, which is what defineAppConfig
returns a Nitro plugin for:
// server/plugins/config.ts
import { defineAppConfig } from "@agent-native/core/server";
export default defineAppConfig({
email: { brandColor: "#0f172a" },
});
When adding a public AgentNativeConfig field, add its deterministic env-path
descriptor and focused parser coverage in packages/core/src/config.ts and
packages/core/src/config.spec.ts. The descriptor is the runtime contract
that keeps JSON fragments, scalar parsing, and unknown-path errors aligned with
the typed public surface.
Four things worth knowing before you write one:
- Wrap a domain in
.prefault({}), never.optional()or.default({}). An optional domain never materializes the defaults declared inside it, and.default({})hands back the literal{}without parsing it. Both leave a reader withundefinedwhere the type promises a value. .meta({ env })belongs on leaf fields only. Collection throws on a group that declares one, because the alias would silently never fire.- A closure is an ordinary field.
z.custom<Fn>()survives.parse(), so a renderer or a per-org resolver does not need a separate mechanism. - An env alias needs a parser for its type. Strings, enums, booleans, and numbers are handled; anything else throws at startup rather than injecting a string into a field that cannot hold one.
envcan be an ordered list, and that is how one concept with many historical spellings collapses:.meta({ env: ["AGENT_NATIVE_APP_ID", "APP_ID"] }). First key that is set wins. A key that is unset, empty, or whitespace counts as absent, matching the?.trim() ||every hand-rolled chain used.
Adding a spelling to an existing field's alias list widens every reader of
that field, not just the one you are looking at. App identity is the worked
example: credential-provider had no APP_ID in its chain, so adding it there
changed which id scopes a credential grant. That can be the right call, but it
is a decision to make deliberately, not a tidy-up.
Resolution order
Lowest opinion first: declared default → env → deprecated set* setter →
defineAppConfig().
Env sits below app code, which inverts twelve-factor on purpose: a typed, reviewed, checked-in value should beat an ambient string on the host. It is safe because nothing above env sets these keys yet, so env still wins every lookup until someone deliberately adds a layer above it.
Values are validated where they are set, so a bad value names the call site that set it rather than whichever unrelated read ran first.
Config or registry?
The distinguishing property is the merge rule, not the number of entries.
Config layers override — the highest layer wins. Registries accumulate — every source's entries coexist.
If a module has getActive*() or a "first wins" rule, it is config wearing a
registry's name. If it has list*() and everything fires, or get(id), it is a
registry and it stays one.
There is a third shape that is easy to get wrong in both directions: accumulate-but-only-one-is-used. Private blob and file upload providers are this. The registry is correct — apps really do add providers — and the defect is only that nothing states which one is active. The fix is additive: keep the registry, add a selector field, keep the old first-configured rule as the fallback so no existing deployment changes behavior.
defineTransactionalEmail is the clearest case of a real registry: core
registers its system emails and each app registers its own. Model that as an
array setting and the app's value replaces core's, so password reset silently
disappears from the catalog.
Antipatterns
- Reading
process.envoutside the four resolvers. Add the field, give it anenvalias. - A bespoke
configure*/set*function for one domain. That is a second namespace with no precedence story and no discoverability. Add a field. - A
register*for something with exactly one active instance. Apply the merge-rule test. - A plugin option that duplicates an env var. The ladder already makes one
field reachable from both.
createAgentChatPlugin({ model })keeps working — the option becomes the top layer of that field rather than a private closure value. - Moving a value to
agent-native.config.tsto avoid threading it.
Deprecated paths
Deprecate, do not delete — core is published, so removal waits for a major and
that is how this gets deferred forever. Mark the old export @deprecated with
"Use X instead", point it at the legacy config layer so it keeps working,
and add a row to the register in
plans/core-configuration-attack-plan.md.
Signals
- GitHub stars
- 5k
- Forks
- 448
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
configuration- Source
- github.com/builderio/agent-native