Module 7 · Tools, Skills & Plugins ⏱ 17 min

Tools Across Environments

By the end of this lesson you will be able to:
  • Resolve the same tool's configuration differently across local, staging, and production via a layered merge
  • Make configuration fail closed on unknown environments instead of falling back to production
  • Gate dangerous capabilities by tier and audit which tools are enabled where they are forbidden

The same query_db tool does very different things in different places. On your laptop it points at a throwaway SQLite file you can delete and rebuild. In staging it reads a test database shared with your team. In production it runs against the real database holding real customers. The code is identical — only the configuration differs — and that gap is where the worst incidents hide: a tool that worked fine in testing silently points at production, sends a real email from a dry run, or charges a real card from a sandbox.

The discipline is environment-scoped configuration: resolve every tool's settings for the environment it is running in, make the safe setting the default, and treat production capabilities as something you opt into, not something you fall back onto.

flowchart LR
  T["same tool<br/>query_db"] --> L["local<br/>sqlite throwaway"]
  T --> S["staging<br/>shared test db"]
  T --> P["production<br/>real db"]
  style L fill:#166534,color:#fff
  style S fill:#b45309,color:#fff
  style P fill:#b91c1c,color:#fff
One tool, three environments: the code is identical; only the resolved configuration differs.

Three tiers, one resolution

Most systems settle on three tiers. Local is the developer's machine: cheap fakes, no network, anything destructive is safe because there is nothing real to destroy. Staging mirrors production but serves no real users: shared test data, deterministic seeds, dangerous side effects still disabled. Production is the live system with real users and real consequences.

The same tool must resolve a different configuration in each. A db connection string, a retries count, a timeout — each can sensibly differ by tier, and the resolution is a layered merge: start from defaults every tier shares, then overlay the values one specific tier overrides. The effective config is the merged result.

resolveConfig merges shared defaults with one tier's overrides; the override wins on conflict. Press Run.
const CONFIG = {
  defaults: { timeout: 5000, retries: 1 },
  envs: {
    local: { db: 'sqlite://local.db', retries: 0 },
    staging: { db: 'postgres://staging', retries: 2 },
    production: { db: 'postgres://prod', retries: 3 }
  }
};

function resolveConfig(env) {
  return Object.assign({}, CONFIG.defaults, CONFIG.envs[env]);
}

console.log(resolveConfig('local').db);
console.log(resolveConfig('local').retries);
console.log(resolveConfig('production').timeout);
flowchart TD
  D["defaults<br/>timeout, retries"] --> M["merge"]
  E["env override<br/>db, retries"] --> M
  M --> R["effective config<br/>for this environment"]
  style M fill:#7c3aed,color:#fff
  style R fill:#166534,color:#fff
Effective config is defaults overlaid with one tier's overrides; the override wins where they disagree.

The production fallback trap

The merge has one famous failure mode. A naive resolver looks up the environment's value and, if the key is missing, falls back to a default — and too often that default is production. Type the environment name slightly wrong, ask for staigng instead of staging, and the resolver finds nothing under that key, shrugs, and hands back the production connection string. A typo now routes test traffic at the live database.

The fix is to fail closed on unknown environments. Keep an explicit set of the environments you actually support, and if the requested tier is not in that set, throw — do not guess. A configuration system should never quietly substitute production for something it did not recognise.

dbFor refuses an unknown environment instead of falling back. Press Run.
const DB = {
  local: 'sqlite://local.db',
  staging: 'postgres://staging',
  production: 'postgres://prod'
};
const KNOWN = new Set(['local', 'staging', 'production']);

function dbFor(env) {
  if (!KNOWN.has(env)) throw new Error('unknown env: ' + env);
  return DB[env];
}

console.log(dbFor('local'));
console.log(dbFor('staging'));

Gating dangerous capabilities by tier

Configuration is not only connection strings — it is also which tools may run at all. A capability like charge_card, send_email, or drop_table carries real-world cost, so the safe rule is to disable it in every tier that is not production and enable it only where it can do real good. Local and staging get a forbidden list: even if a tool is wired up, the gate refuses to run it. Production, the one tier with real users, is the only place these capabilities switch on.

This inverts the usual default. Instead of enabling a tool everywhere and disabling it where it hurts, you disable it everywhere and enable it in the single tier that earns it — production. The asymmetry matches the asymmetry of harm.

flowchart TD
  C["charge_card tool"] --> G["which environment"]
  G --> L["local: disabled"]
  G --> S["staging: disabled"]
  G --> P["production: enabled"]
  style L fill:#b91c1c,color:#fff
  style S fill:#b91c1c,color:#fff
  style P fill:#166534,color:#fff
A costly capability is disabled in local and staging and enabled only in production.

Secrets are not configuration

Connection strings, API keys, and tokens are secrets, and they follow a stricter rule than ordinary config: they never live in the repository, never appear in the resolved object that gets logged, and are injected at runtime from a secret store keyed by environment. The resolver fetches the name of a secret from config and the value from the store — so a config file can name DB_PASSWORD in every tier without ever holding the production password. Treating secrets as ordinary config is how credentials leak into version control and build logs; the environment scope that protects your database must protect its password too.

findUnsafe returns tools enabled in an environment where they are forbidden. Press Run.
const FORBIDDEN = {
  local: new Set(['send_email', 'charge_card', 'drop_table']),
  staging: new Set(['charge_card', 'drop_table']),
  production: new Set()
};

function findUnsafe(tools, env) {
  const ban = FORBIDDEN[env];
  return tools.filter(function (t) { return t.enabled && ban.has(t.id); }).map(function (t) { return t.id; });
}

console.log(findUnsafe([{ id: 'send_email', enabled: true }], 'local'));
console.log(findUnsafe([{ id: 'send_email', enabled: true }], 'production'));
console.log(findUnsafe([{ id: 'send_email', enabled: false }], 'local'));
Exercise

You add a charge_card tool. What is the safe default for which environments it runs in?

Exercise

What does this print (two lines)? This resolver falls back to production when the environment is unknown — exactly the danger the lesson warns about.

const DB = { local: 'sqlite://local.db', staging: 'postgres://staging', production: 'postgres://prod' };
function dbFor(env) { return DB[env] || DB.production; }
console.log(dbFor('staging'));
console.log(dbFor('qa'));
Exercise

Write resolveConfig(env) that returns the effective configuration for an environment: a fresh object that merges CONFIG.defaults with CONFIG.envs[env], where the environment's values win on conflict. (CONFIG is already defined for you.)

const CONFIG = {
  defaults: { timeout: 5000, retries: 1 },
  envs: {
    local: { db: 'sqlite://local.db', retries: 0 },
    staging: { db: 'postgres://staging', retries: 2 },
    production: { db: 'postgres://prod', retries: 3 }
  }
};
function resolveConfig(env) {
  // merge defaults with the override for this env (env wins on conflict)
}
Exercise

This dbFor falls back to PRODUCTION credentials for any environment it does not recognise — a typo like staigng silently routes test traffic at the live database. Fix it so an environment not in the known set throws instead. (DB is already defined.)

const DB = { local: 'sqlite://local.db', staging: 'postgres://staging', production: 'postgres://prod' };
function dbFor(env) {
  return DB[env] || DB.production;
}
Exercise

Write findUnsafe(tools, env) that returns the ids of tools that are enabled in env but whose id is on that environment's forbidden list. FORBIDDEN maps each environment to a Set of forbidden tool ids. Return only the ids (not the whole tool objects). (FORBIDDEN is already defined.)

const FORBIDDEN = {
  local: new Set(['send_email', 'charge_card', 'drop_table']),
  staging: new Set(['charge_card', 'drop_table']),
  production: new Set()
};
function findUnsafe(tools, env) {
  // ids of tools enabled in env but on this env's forbidden list
}

Recap

  • The same tool resolves different configuration in each tier: local (cheap fakes), staging (shared test data), production (real users).
  • resolveConfig(env) is a layered merge — shared defaults overlaid with one tier's overrides, the override winning on conflict.
  • Fail closed on unknown environments. A typo'd tier must throw, never fall back to production credentials — silent production substitution is the most expensive default in this layer.
  • Secrets are not config: they live in a runtime store keyed by environment, never in the repository or the logged config object.
  • Gate dangerous capabilities by tier: charge_card and friends stay on a forbidden list outside production; findUnsafe(tools, env) audits that gate up front.

That closes the module: define a tool, dispatch it safely, discover the right one, bundle it into a skill, sandbox it, and configure it across every environment.

Checkpoint quiz

A resolver is asked for the 'qa' environment, which it does not know. What should it do?

Where should a charge_card capability be enabled by default?

Go deeper — technical resources