Skip to content

Routing & configuration

Every message resolves to exactly one (agent, model, effort) triple through layered config, and permission gates run against the resolved agent so no layer can smuggle a restricted agent past them.

  • Code: src/directives.ts, src/config.ts, src/providers/registry.ts (the provider table config.yaml names), src/config/validate.ts (the load-time validators and MAX_INSTRUCTIONS_LENGTH, the cap they enforce), src/core/envFile.ts + src/loadEnv.ts (item 18), src/secrets.ts + src/secretEnv.mjs (item 19: the Secret wrapper and the lint that keeps every credential behind it), src/core/dashboardAuthConfig.ts (the dashboard block, item 17), src/effort.ts, src/core/dispatcher.ts, src/core/dispatch/fastPath.ts (item 10: stage A and the op translation, answered before any model turn), src/core/dispatch/resolve.ts (items 1–3: readRequest, resolveRun — the triple through the layers with thread stickiness — and resolveTarget), src/core/dispatch/authorize.ts (item 4: authorizeAgent against the resolved agent, authorizeRepo for the repository gates), src/core/capabilities.ts (item 16), src/core/selfDescription.ts + src/core/residentFleet.ts (item 11), src/core/configAwareness.ts, src/core/customInstructions.ts, src/core/commands/config.ts + src/core/commands/help.ts (the config.* / help.show registry commands), src/core/commands/repo.ts + src/core/operations.ts (deterministic ops)
  • Docs: Why config is layered, AGENTS.md invariants 3, 4, 7
  • Tests: src/directives.test.ts, src/config.test.ts, src/core/envFile.test.ts, src/secrets.test.ts + src/secretEnv.test.ts (item 19), src/core/dashboardAuthConfig.test.ts, src/core/dispatcher.test.ts, src/core/dispatch/fastPath.test.ts, src/core/dispatch/resolve.test.ts, src/core/dispatch/authorize.test.ts, src/core/capabilities.test.ts, src/core/selfDescription.test.ts, src/core/residentFleet.test.ts, src/core/configAwareness.test.ts, src/core/customInstructions.test.ts, src/runner.test.ts (effort reaches the provider)

Behavior

  1. Inline directives (agent:review model:provider/model effort:low, anywhere in the message) set agent/model/effort for that request and are stripped from the text the model sees. Unknown agents are rejected with the available list; unknown models pass through (the provider errors); an effort outside low | medium | high | xhigh | max is rejected with the valid list (the Anthropic provider clamps xhigh/max to high on Opus/Sonnet ≤ 4.6, which 400 on them, and omits effort on Haiku — run-loop.md item 11) (src/effort.ts is the one definition of the levels).

  2. Resolution precedence: request directive > thread-sticky > user scope > channel scope > defaults. Model additionally honors forced models (user.model/channel.model) over per-agent maps. Effort is a first-class dimension with the identical ladder: effort: directive > thread-sticky > forced user.effort > forced channel.effort > per-agent user.efforts.<agent> > channel.efforts.<agent> > defaults.efforts.<agent>; when no layer sets it, resolve() returns no effort and the agent definition's built-in effort (then the provider's default) applies in the runner — so an agent's registry value is a floor every deployment, channel, user, thread, or message can override, never a hardcoded tier. Invalid levels are rejected at load (config.yaml, hand-edited overrides.json) and on write (config set).

  3. Thread stickiness: a follow-up without directives runs on the agent/model/effort the thread last used — derived from the thread's own history (user turns, last directive wins) on every message, never stored. Assistant turns can't set it; unknown agents in history are skipped, never thrown.

  4. Permission gates run post-resolution: an agent under restrict.agents admits only holders of agent:run:<name> (admins through all); an unlisted agent is open to everyone. config set channel needs the config:write grant — never a baseline (authorization.md item 9). Per-repo access for resident environments follows the same shape (restrict.repos against the user's repos axis): an unlisted repo is open to every user who may run the coding agent; a listed repo refuses ungranted users with a named 🚫 refusal in the dispatcher before any executor is created — never a silent per-thread fallback (admins always pass). Repo management needs repo:write, never a baseline: admins only until granted — repo onboard/rebuild provision billable always-on compute and bind GitHub credentials.

  5. Config commands are registry commands (command-registry.md item 20): help (= help show), config show [--channel <id>], config set <channel|me> [--agent x] [--model p/m] [--models.<agent> p/m] [--effort e] [--efforts.<agent> e], config clear <channel|me>, config instructions <channel|me> [text…] — answered inline through the registry's chat adapter and never reach a model; the same commands are /api/config.*, MCP config_*, and npx tsx src/cli.ts config … (a machine caller names the channel with --channel). Runtime overrides persist through the overrides backing (item 12) and win over static config for the same scope. config show renders the effective effort and every scope's effort keys where set. Semantic checks (unknown agent, bad effort, nothing to set) are named without echoing the value (⚠️ \config set`: effort: expected one of "low", "medium", "high", "xhigh", "max"`).

  6. Repo-management commands (repo list/onboard/offboard/reconfigure/rebuild) are registry commands too: inline replies, no model turn, gated per item 4 (only repo list is open; the rest are repoManager = canManageRepos). Details and validation live in resident-repos.md items 32–36 and command-registry.md item 20; help lists them.

  7. Deterministic ops (command-registry.md item 24): repo test <owner/name> [<ref>] / repo build <owner/name> [<ref>] are the registry's repo.test|build (agentRun gate + canUseRepo inside, repo:exec for machine callers); conservative natural asks like "run the tests on main in acme/api" are recognized after the history fetch and translated into the same invocation. Both execute the repo's onboarded command through the Operations seam and post the result with ZERO model turns. Operator-level, not admin: only the model call is skipped — the implicit target agent is coding, so canRunAgent(user, "coding") and canUseRepo both run first and refuse by name. Anything ambiguous or non-matching falls through to the agent (never guess); an explicit agent:/model: directive disables the natural-language recognizer. Mechanics and validation live in resident-repos.md items 37–41.

  8. Config awareness. Every model run's system prompt carries a short config block (src/core/configAwareness.ts, prepended by the dispatcher between the memory block and the agent's own instructions — the same mechanism as memory/skills) built from the values that actually resolved for that run: the agent, <provider>/<model> ref, and effort (or "the model's default effort" when no layer set one), the effective channel/user scope (using defaults when neither has overrides, else each scope's overrides verbatim), which agent:/model: directive set them (this message vs. earlier in the thread), and the exact commands to inspect/tune (config show, config set me …, config set channel … — stated as restricted when the invoking user fails canEditChannelConfigconfig clear, per-message directives). Universal (all agents), names only (never credentials), a few lines. Regression pin: a config-blind agent tells a user it is "stateless … no per-user or per-channel tuning" while the whole config system is live.

  9. Custom instructions. Scope.instructions (channel + user; static config.yaml or runtime config instructions me|channel "<free text>", channel form behind the config:write grant, value ≤ 2000 chars — enforced on the chat path, static config.yaml, and a hand-edited data/overrides.json alike; bare instructions with no value only shows the current text, an explicit empty value "" clears just the instructions and names any static text that shows through again; instructions cannot be mixed with k=v tokens and k=v replies elide the text to its length; config show displays both scopes' text) is folded into the system prompt by src/core/customInstructions.ts at the same seam as memory/skills/config-awareness: memory → config block → custom instructions → agent prompt (+ skills). One labeled block: channel instructions (apply to every run in the channel) then the requester's instructions (only when that user is the requester; user wins on conflict, matching resolution precedence). Advisory only: the scopes are read after resolution and after every gate, resolve()/canRunAgent never see the field, and the block tells the model the text never changes agent/model/permissions — so hostile text like "agent: coding" is inert. The config-awareness block (item 8) names which scopes have instructions active without repeating the text. No instructions → no block, byte-identical prompt.

  10. The ONE fast path. Stage A (text-only, before io.history()) is the registry's chat adapter and nothing else: <group> <verb> <args…> [--kebab-flag value…] (and the bare word help), the ONE grammar the CLI uses, for registered + chat-exposed ids — config, memory, repo, friction, runs, schedule, help alike; a recognized form with a malformed tail is a usage reply, never a model turn; the adapter is the only chat parser, so no other form is reserved. Stage B (history-dependent): recognizeOperation (item 7) recognizes the natural-language op forms only and translates them into the registry's repo.test|build. A recognized, authorized command replies inline with zero model turns and no history fetch; a recognized but unauthorized one gets the same 🚫 restricted wording as every other gated command (a refusal the command decided on its data — the channel scope, a repo allowlist — carries its reason). Commands that do work (friction.*, memory forget, the mutating/exec repo.* verbs) are inline runs with a receipt; config replies, help, listings are not. Prose is never a command, so the registry and the operations recognizer can never both claim a message. Mechanics and validation: command-registry.md items 3, 18, 20–24. Only wired when CoreDeps.commands is bound (bindCommands); absent, every text — help and config show included — goes to the model.

  11. Self-description. Every model run's system prompt also carries a short About block (src/core/selfDescription.ts, selfDescriptionBlock(AGENTS, config.organization, capabilities, residentCap, build), composed right after the config block — memory → config → about → custom instructions → the agent's own instructions) stating what Switchboard is: which build is answering (CoreDeps.build — the package version and the image's stamp, unknown when nothing stamped the process, named as such and never guessed; the same facts status show reports live, so "what version are you running?" is answered from fact), whose gateway it is (the config's organization — never a name in the code), the channel → agent → provider → executor shape and its surfaces, every registered agent with its description (built from the live registry, so the list cannot drift), and — as a function of the process's capabilities (item 16), so it never describes a subsystem this installation lacks — how residents work and are managed when residents is on (repo onboard/list/reconfigure/rebuild/offboard, onboarded = warm — there is no separate "priority repos" setting, and the fleet's cap as the resident Worker itself reports it on GET /residents, read in the background by watchResidentFleet (src/core/residentFleet.ts, at boot and every five minutes, never on the run path; CoreDeps.residentFleet) — and only when the admin plane can answer: residentFleetWatcherFor starts no watcher on the null admin client (a resident Worker named but its admin bearer unset), whose 503s could never yield a cap and would only warn every five minutes — "capped (repo list shows the cap)" until it has answered, never a constant compiled into the bot) or, without residents, that repos are cloned into a per-thread workspace where execution.type says (bot host / E2B / Cloudflare sandbox) with nothing to onboard; runs (/runs, "kept as run history" only with runHistory), memory only with memory (memory list / memory forget; otherwise that a run knows only its thread), the MCP surface only with ingress; and where the source and the docs/reference/specs/*.md specs live (this repository) — with the instruction to read them with the GitHub tools when the agent has them and otherwise to answer from the block and point at the path, never to describe itself as stateless or unable to know its own workings. Five lines in every configuration. Regression pin: asked how the resident system works, a research run without the block searches the public web, gets a 404 on the repository, and answers "repo is private, inaccessible". Ship children carry the same block.

  12. Runtime overrides are durable, off the host. Every chat-set value (config set, config instructions, config clear) is one document — { channels, users } of Scopes — persisted through the OverridesBacking seam (src/config.ts), two implementations (AGENTS.md invariant 2): FileOverridesBacking (a JSON file, data/overrides.json by default — local dev and single-host deployments) and WorkerOverridesBacking — the ConfigDO on the state Worker (deploy/cloudflare-memory/, migration v5; POST /config/get|put under the shared MEMORY_TOKEN bearer; /healthz lists config), the production choice because Cloudflare Containers wipe the disk on every restart (invariant 6). config.yaml picks it: runtimeOverrides.worker: { baseUrl, tokenEnv? } (https only, validated at load; a configured Worker without its bearer is a startup error, never a silent fall back to the ephemeral file). openConfigStore — what src/index.ts and the CLI call — parses the YAML, chooses the backing, loads the document ONCE, and constructs the store; a hand-edited or stored document is held to the same caps as the chat path at load (instructions length, effort levels), naming the backing. Writers are async and transactional at the document level: set*/clear* mutate a copy, persist the whole document, then adopt it — a failed save (Worker down, version conflict) leaves the in-memory document exactly as it was and surfaces the error, so what the running bot resolves is always what the store holds; the config.* commands report it as unavailable. Writes within one process are serialized (two config sets in flight at once cannot drop each other's change). One document, two writers: the bot and the CLI (npx tsx src/cli.ts config set …) open the same backing, so a config set from a terminal is what Slack sees on the next message; the ConfigDO keeps a version per document and refuses a put whose expectedVersion is stale (409). A refused save is rebased, never retried blind: the store reloads the current document, adopts it, re-applies the same change on top and saves once more, so neither writer's change is lost; a second refusal in a row surfaces the conflict (unavailable, "retry the command") with the store already holding the other writer's document, so that retry rebases too. The CLI starts the open up front but nothing awaits it except the config-backed deps themselves (buildCoreCommands binds every one — config.*, runs, friction.ledger/config, repo.admin/operations/canUseRepo, memory.config — behind an async accessor), so a command that never touches the config never waits for it and no command is classified anywhere: a slow or unreachable Worker costs deploy plan, help, env nothing, and a config it cannot open (missing file, a configured Worker without its bearer, an unreachable Worker) fails only the command that needs it, naming the cause. Documents are small (one entry per channel/user that ever set something) and capped at 256 KB (UTF-8 bytes) on the Worker.

  13. mcpServers is a scope setting (mcp-tools.md items 11–17). External MCP servers ride the same layers as models and instructions — defaults.mcpServers (the org tier), channels.<id>.mcpServers, users.<id>.mcpServers — static in config.yaml or written at run time by the mcp add|remove commands into the same overrides document (the org tier's runtime half is Overrides.org, setOrgOverride). Unlike every other setting, which the runtime override replaces whole, a tier's runtime mcpServers map layers over its static one per name (layerScope), so a runtime add never hides a pinned server. Unlike the other settings a run takes the union of the tiers (mcpServersFor), and a name clash resolves to the highest-trust tier (org > channel > user). validateMcpServers runs at load on every layer, config show lists each tier's server names, and the config-awareness block carries an MCP line. The mcp.* commands are the only writers; config set does not take --mcp-servers.

  14. The base config is a document too. On Cloudflare the image carries no config.yaml: deploy config (and deploy all, right before the bot step — release-and-deploy.md item 15) reads the operator's config from the profile's configSource, validates it, and pushes it as the base document on the same ConfigDO that holds the overrides — { yaml, sha256, source, pushedAt }, the YAML as written so comments survive, under optimistic versioning like every document there (src/configDocument.ts). The bot reads it at startup when SWITCHBOARD_CONFIG is state://base (the bot Worker's containerEnv sets it; STATE_WORKER_URL and MEMORY_TOKEN say where and how), logging the version, source and digest it started on; a file path in SWITCHBOARD_CONFIG still reads a file (local dev, docker compose). A state:// location without the Worker's URL or bearer, an unreachable Worker, a document that is not a base config, or no document at all is a startup error naming the variable or deploy config — never a silent empty config. The running container keeps the config it started with, so a pushed change goes live on deploy restart, exactly as a rotated secret does.

  15. organization is required. The GitHub organization (or user) this installation serves — the account its GitHub App is installed on. It names the shared memory scope (org:<organization>, memory.md item 4) and the About block (item 11); a config without it, or with an empty or non-string value, fails validation naming the field — at deploy config before anything deploys, and at startup. Nothing in the code assumes an organization: one image serves every installation.

  16. One Capabilities value, computed once; a Null Object for every off-state. What is ON in a process — execution (local | e2b | cloudflare), residents, memory, runHistory, runLedger, mcp, costs, schedules, github, ingress, dashboardAuth (access | token | none), docs — is capabilitiesFrom(config, env, secrets) (src/core/capabilities.ts), resolved ONCE at startup by src/index.ts and src/cli.ts and handed down as CoreDeps.capabilities (Fowler's Feature Toggles, resolved once). Its rules mirror the builders that select each subsystem's implementation (buildRunStore, buildRunLedger, buildScheduleStore, parseMcpSettings, the costs wiring, the Access gate) and a test pins each axis to its builder; a malformed costs or mcp block throws exactly where those throw. No surface re-derives a capability from config: the command catalogue hides what is off (command-registry.md item 28), the About block describes only what is on (item 11), the status card's not-onboarded note is a resident installation's (the gate reads capabilities.residents — without a fleet a rejected slug is no reason to stop, and repo onboard would name a command the installation does not have), the web seed carries the value for the dashboard (WebSeed = PageSeed & { capabilities }, stamped by the shell renderer, never by a view), and the deploy plan iterates the profile's Workers (release-and-deploy.md item 14). Every optional subsystem is wired as a real implementation or its Null Object (GoF; Fowler's Introduce Special Case), selected in index.ts/cli.ts from the capabilities, so the core never asks whether a store, a ledger or a source exists — it calls it: NullMemoryStore, NullRunStore, NullRunHistoryWriter (every write dropped, onPersisted never called), NullLedgerWriteThrough (nothing claimed — open answers as for an untracked run; adopt a detached run whose finish sink is the plain store), NullScheduleStore, NullFrictionLedger, NullMcpToolSource (no server scoped, so no tools and no block), NullCostsService (no group; the costs page renders its reason as 503), NullResidentAdminClient (a Special Case: every route 503 with the reason — no resident Worker, or its admin bearer unset — which the residents page and repo list render). CoreDeps.memory, mcp, runHistoryWriter, runLedger and threadsElsewhere are therefore required, and the dispatcher's presence checks on them are gone (the discovery span dispatch.mcp_discovery runs on every dispatch, trivially with the null source). The drain and reclaim logic in index.ts keeps its checks and reads capabilities. Adding a capability is one field in capabilities.ts plus the enabledWhen predicates that name it.

  17. dashboard picks the dashboard auth strategy (access-gate.md, plan D5). dashboard.auth: access | token | none names which credential gates /runs*, /residents*, /costs*, /mcp/connect/* and /api/*; dashboard.token.env (default DASHBOARD_TOKEN) and dashboard.token.actor (access:<name>, required for token) are the token strategy's inputs. Absent → access when ACCESS_TEAM_DOMAIN and ACCESS_AUD are set, else none. The block is validated at load like every other (src/core/dashboardAuthConfig.ts): a non-mapping, an unknown key, a misspelled mode (never read as none), a blank token.env, an actor outside access:<name> (a service-token id included), or auth: token without an actor is a load error naming the key. The environment half — the env var set, ACCESS_* present for access, a localhost PUBLIC_BASE_URL for an explicit none — is checked when src/index.ts composes the verifier, and fails startup by name.

  18. A .env in the working directory is loaded at startup. Every credential is read from the environment (apiKeyEnv, tokenEnv, the Slack tokens) and never from a config file, so both entry points (src/index.ts, src/cli.ts) import src/loadEnv.ts first, which loads ./.env — relative to the working directory, the repo root when you run from it — with Node's own parser when the file exists (src/core/envFile.ts). A variable the shell already set wins over the file's, exactly as node --env-file behaves; a missing file is the normal case in a container and is not an error; any other failure (a directory, an unreadable file) is raised. Nothing else in the tree reads the file.

  19. Every credential is a Secret; the value leaves through reveal() and nowhere else. src/secrets.ts is the one module that reads a credential from process.env: processSecrets.get(<name>) for a name deploy/secrets.manifest.json lists (or one of the two dev fallbacks, GH_TOKEN and E2B_API_KEY — any other name is a programming error and throws), processSecrets.named(<var>) for a variable the operator's config names (apiKeyEnv, tokenEnv, credentialKeyEnv, dashboard.token.env), require() for the two the process cannot start without; values are trimmed (a .env line or a pasted secret often carries a trailing newline), so unset and blank are both "not set". A Secret carries its name and holds the value in a private field: ${s}, String(s), concatenation, JSON.stringify, util.inspect (so console.log), an Error message built from it, Object.keys, spread and structuredClone all yield [secret:<NAME>] or the name alone — the redaction net of run-visibility.md still covers strings that came from elsewhere, but a wrapped value never needs it. reveal() is called where the value crosses a boundary and not before: a third-party SDK's constructor (Bolt, the Anthropic client, E2B), an Authorization or X-Subscription-Token header, the sandbox's env, the constructor of one of our own single-remote clients (WorkerRunStore, ResidentExecutor, …). The builders that used to take an environment record take a Secrets (buildRunStore, buildMemoryStore, buildScheduleStore, buildMcp, residentAdminFromConfig, parseIngressTokens, capabilitiesFrom, openConfigStore, buildDashboardVerifier); code that wants an environment record for public variables gets publicEnv(), the environment minus every secret name. The lint makes it a guarantee: secrets/no-raw-env (src/secretEnv.mjs, wired in eslint.config.mjs, part of npm run lint and so of verify and CI) refuses, in every production file under src/ but src/secrets.ts and src/loadEnv.ts, a process.env.<secret> read by name, a computed process.env[x], a bare process.env value (passed, spread, stored or destructured) and a name that is neither a secret nor on the public list (PORT, NODE_ENV, PUBLIC_BASE_URL, STATE_WORKER_URL, ACCESS_*, SWITCHBOARD_*); the operator-side tooling (src/deploy, src/agentEnv, src/setup) keeps its bare and computed reads — it spawns wrangler and op with the operator's environment — and is still refused a secret read by name. Tests and fixtures are exempt. Out of scope, deliberately: the Workers (deploy/*/worker.ts read env bindings, a different surface) and credentials minted at run time (a GitHub App installation token), which are strings from an API, not the environment.

Validation criteria

CriterionProof
dashboard (item 17): each mode accepted and exposed as written, absent by default; a misspelled mode, an unknown key, a non-mapping and token without its actor refused at load naming the key; the pure validator's every case[unit] src/config.test.ts::dashboard::*, src/core/dashboardAuthConfig.test.ts::validateDashboardConfig…::*
A config.yaml top-level key the document does not define fails the load naming it — a setting that does not exist (permissions) and a misspelling alike, never mapped or ignored; the example config loads[unit] src/config.test.ts::restrict — closed unless granted (authorization.md item 11)::an unknown top-level key is refused at load naming it…, src/config.test.ts::ship caps block (agent:ship pipeline)::the example config (config/config.example.yaml) still loads through ConfigStore
The example config's commented providers blocks are real configurations: the OpenRouter block, uncommented, loads, and ProviderRegistry builds an openai-compatible provider from it whose baseUrl has no trailing slash whether or not the config wrote one[unit] src/config.test.ts::the example config's commented provider blocks::the OpenRouter block, uncommented, loads and ProviderRegistry builds an openai-compatible provider from it with the trailing slash stripped from baseUrl
Directives extract and strip; unknown agent rejected with list[unit] src/directives.test.ts::parseDirectives
Sticky derivation: last-wins, user-turns-only, lenient[unit] src/directives.test.ts::lastThreadDirectives…
Full precedence matrix incl. forced vs per-agent models[unit] src/config.test.ts::layered resolution
Effort ladder: unset → undefined; defaults.efforts floor; user/channel per-agent; forced beats per-agent; directive beats all; runtime overrides persist and clear; invalid static values rejected at load naming the levels; config show renders effort[unit] src/config.test.ts::effort resolution (the same layers as model)
effort: directive extracts/strips, rejects unknown levels with the list, is thread-sticky (user turns, last wins, lenient)[unit] src/directives.test.ts::parseDirectives — effort
A resolved effort reaches the provider request and overrides the agent definition's; absent → the definition's applies[unit] src/runner.test.ts::effort…
End-to-end: directive → provider effort + awareness attribution; config set me effort= / efforts.<agent>= persist and forced beats per-agent; sticky follow-up; unset → no effort on the request; turbo / unknown agent refused inline with no model call[unit] src/core/dispatcher.test.ts::effort resolves like model…, ::effort is thread-sticky…, ::an unset effort leaves the provider request without one…
Awareness block names the effort (or the model's default), scope effort overrides, effort directive attribution, and the effort tuning commands[unit] src/core/configAwareness.test.ts::configAwarenessBlock — effort
Runtime overrides beat static config and clear cleanly[unit] src/config.test.ts::layered resolution::runtime overrides win over static config…, ::effort resolution…::runtime overrides set effort per scope and per agent, persist through the store, and clear
Overrides backing (item 12): a preloaded backing is read; every write saves the whole document once; a failed save rolls back in memory and surfaces; a stale save is rebased on the reloaded document (both writers' changes kept, one save); a second stale refusal surfaces the conflict with the store holding the other writer's document; concurrent in-process writes are serialized and a failed one does not block the queue; a stored document over the caps is refused at construction naming the backing; overridesBackingFor picks file / Worker / errors on a missing bearer; runtimeOverrides validated at load; openConfigStore loads the document and parses config.yaml once (warnings not duplicated)[unit] src/config.test.ts::overrides backing (item 12: durable runtime overrides)
WorkerOverridesBacking: load with version, save with expectedVersion and the bearer, version tracking, empty store → undefined/0, stale save → OverridesConflictError (retry message) + adopts the current version, non-2xx / transport named[unit] src/config.test.ts::WorkerOverridesBacking (the ConfigDO client)
ConfigDO: feature advertised, auth + method enforced; unknown key → null/0; put creates v1 / replaces to v2; stale expectedVersion → 409 with the current version and nothing clobbered; bad key / non-object / bad version / oversize (UTF-8 bytes, not code units) refused; unknown route 404[unit] deploy/cloudflare-memory/config.test.ts (workerd)
The CLI starts the same open the bot does, but every config-backed dep reaches for it through an async accessor, so only a command that touches the config waits for it: with a state Worker that never answers, deploy plan and help show return at once and never ask, while config show asks (and waits); a config it cannot open (missing file, bearer unset, Worker down) fails only the command that needs it, naming the cause[unit] src/cli.test.ts::loadBotConfig…, ::bindBotConfig…, ::(a) \deploy plan` succeeds without ever asking for the bot config…`
mcpServers as a scope setting (item 13): union of the tiers, org wins a name clash, runtime over static per tier, config show names them, every layer validated at load[unit] src/config.test.ts::Scope.mcpServers (MCP servers layered through config)
Live: a config set me --effort low in Slack survives a bot restart (deploy restart), and npx tsx src/cli.ts config show --channel <id> on a laptop shows the same override[agent] @switchboard config set me --effort lowdeploy restart@switchboard config show still shows effort low for you; the CLI config show --channel <that channel> renders the same scope.
Restricted agents admit only grantees and admins, unrestricted ones everyone; config:write is never a baseline[unit] src/config.test.ts::permission gates
Per-repo access: open-when-absent, allowlist admits members + admins, named refusal without an executor[unit] src/config.test.ts::per-repo access (canUseRepo), src/core/dispatcher.test.ts::resident repo dispatch::a canUseRepo refusal is a named reply and no executor is created
Repo management fail-closed: repo:write is never a baseline — admins only until granted; repo list open[unit] src/config.test.ts::repo management gate (canManageRepos), src/core/commands/repo.test.ts::gates … and scopes, src/core/dispatcher.test.ts::repo management commands …
Config commands never call a model; help is the derived help; config set me --model … / --effort / --efforts.<agent> persist and reflect in the awareness block; bad values refused inline with the shared wording; --instructions on config set is a usage reply[unit] src/core/dispatcher.test.ts::answers config commands inline…, ::config awareness in the system prompt::*, ::custom instructions in the system prompt::\instructions` is its own command…; the commands themselves: src/core/commands/config.test.ts::, src/core/commands/help.test.ts::`
Deterministic ops answer with zero model turns; gates still apply (the agentRun registry gate, canUseRepo inside), the explicit form is stage A and the NL form stage B, both reach repo.test[unit] src/core/dispatcher.test.ts::deterministic ops fast-path … (zero provider calls, coding-allowlist + per-repo refusals, ambiguous fallthrough, named refusals for hostile refs), ::defaultOperations backend selection…, src/core/operations.test.ts, src/core/commands/repo.test.ts::repo test / repo build…; live check in resident-repos.md.
The registry parse is the whole of stage A, before history/recognizeOperation; no reserved forms (repo onboard x → the schema's refusal via repo.onboard); repo list and both friction forms reach the registry exactly once; admin runs list inline with zero model turns; prose → model; no CoreDeps.commands → everything is prose[unit] src/core/dispatcher.test.ts::registry chat commands in the fast-path chain …, src/core/commandChat.test.ts
Denied agents produce 🚫 without a model call[unit] src/core/dispatcher.test.ts::denies restricted agents…
Follow-ups stick; explicit directive overrides; gate still applies[unit] src/core/dispatcher.test.ts::dispatch::thread follow-ups stick to the agent the thread established, ::an explicit directive on the follow-up overrides the sticky agent, ::sticky resolution still passes the permission gate
Config block renders resolved agent/model, defaults vs. per-scope overrides, directive attribution (message vs. thread), tuning commands with truthful channel gating, and stays short[unit] src/core/configAwareness.test.ts
Every dispatch's system prompt carries the block reflecting the ACTUAL resolved state: defaults, config set me override, channel-forced agent, per-message directive, sticky thread directive, per-user channel gating; memory still leads and skills still trail[unit] src/core/dispatcher.test.ts::config awareness in the system prompt
11: the About block names the build it runs (version + short commit with status show as the live source; "an unstamped build" when the commit is unknown; the first line unchanged when the caller has no build facts) and the configured organization, every registered agent + description, only chat commands that exist IN THAT INSTALLATION (resident and memory commands with everything on, none of them with nothing on), the repo + specs path; with residents on the mechanism, onboarded = warm and the cap the resident Worker reported (or where to read it while unknown), with residents off no onboarding paragraph and the per-thread workspace's place per execution type; run history, memory and the MCP surface only when on; is 5 lines in every configuration; it rides on every dispatch exactly once, after the config block and before the agent's instructions, following deps.capabilities and deps.residentFleet.cap()[unit] src/core/selfDescription.test.ts::selfDescriptionBlock::*, src/core/dispatcher.test.ts::self-description in the system prompt … and the github_* tools::every run's prompt carries the About block*, ::the About block follows the process's capabilities and the resident Worker's own cap…
11: the fleet facts — unknown until the resident Worker's listing answers, then its cap, updated by a later listing; a non-200, a throw or a non-numeric cap leaves the last value and warns; start() reads at once and on the (unref'd) interval, stop() clears it; NO_FLEET knows nothing[unit] src/core/residentFleet.test.ts::watchResidentFleet::*, ::NO_FLEET knows nothing, ::residentFleetWatcherFor — only an admin plane that can answer is watched::*
16: capabilitiesFrom — a bare config in an empty environment is the minimal installation (everything off, local, dashboard auth none); each axis turns on with its config/env and off without: execution.type; execution.resident.baseUrl; memory.enabled; runHistory/runLedger pinned to buildRunStore/buildRunLedger over file, Worker, bearer-less and renamed-token cases; mcp pinned to parseMcpSettings (a malformed block throws); costs needs the block AND its Cloudflare token (default or named env); schedules pinned to buildScheduleStore; github the App triple or GH_TOKEN; ingress at least one bearer (malformed JSON is off); dashboardAuth is resolveDashboardAuthMode over config.dashboard.auth and parseAccessConfig (item 17 — the rule the verifier runs; ACCESS_DEV_BYPASS is not read); the full configuration reaches ALL_CAPABILITIES[unit] src/core/capabilities.test.ts::capabilitiesFrom — every axis, on and off::*
16: every Null Object honours its seam with empty answers — NullRunStore (put accepts and keeps nothing, reads are the not-found shape), NullRunHistoryWriter (writes dropped, nothing pending or failed, settles at once), NullLedgerWriteThrough (open undefined, nothing live, empty inbox and handoff; adopt a detached untracked run finishing unavailable whose sink is the plain store), NullScheduleStore, NullFrictionLedger (and selectFrictionLedger(null) is it), NullMcpToolSource (no tools, no block), NullCostsService (no group, a report refused with the reason), NullResidentAdminClient (every route 503 with the reason, withSpan itself)[unit] src/core/runStore.test.ts::NullRunStore — the store of a process without run history::*, src/core/runHistoryWriter.test.ts::NullRunHistoryWriter — the writer of a process without run history::*, src/core/runLedger/writeThrough.test.ts::NullLedgerWriteThrough — the write-through of a process without a ledger::*, src/core/scheduleStore.test.ts::NullScheduleStore — the store of a process without a firing store::*, src/core/frictionLedger.test.ts::NullFrictionLedger — the ledger of a process without run history::*, src/mcp/source.test.ts::NullMcpToolSource — the source of a process without MCP::*, src/core/costs.test.ts::NullCostsService — the service of a process without cost reporting::*, src/core/residentAdmin.test.ts::NullResidentAdminClient — the admin plane of a process without residents::*
16: the views render the null object's reason as their 503 — the costs page with no group, the residents page from the null admin client — never a branch on a missing service[unit] src/channels/costsView.test.ts::createCostsViewHandler::503s with a pointer to the config when the process has no cost reporting…, src/channels/residentsView.test.ts::createResidentsViewHandler::503s with a plain explanation when the process has no residents…
16: the not-onboarded note is a resident installation's — with residents off the same rejected slug starts no refusal and never mentions repo onboard; with residents on the not-onboarded replies stand[unit] src/core/dispatcher.test.ts::resident repo dispatch::the not-onboarded note is a resident installation's…, ::fresh thread + rejected bare slug + a repo-needing agent → one not-onboarded reply, no run …
16: the dispatcher runs unchanged over the null objects — the whole suite passes with makeDeps wiring them (memory, MCP source, writer, ledger, threads elsewhere, fleet facts) and no test constructs an optional dep; the web seed island carries capabilities, stamped by the shell[unit] src/core/dispatcher.test.ts::*, src/channels/webShell.test.ts::*, src/channels/liveView.test.ts::createLiveViewHandler (node:http)::serves the live-run shell for a valid id+token…
Instructions store on both scopes, persist, clear restart-consistently (undefined patch deletes the key), never touch resolve()/gates, static over-cap rejected at load (both config.yaml and overrides.json), config show renders them[unit] src/config.test.ts::custom instructions (Scope.instructions)
Block renders channel/user labels, user-wins note only when both present, advisory contract, nothing when empty[unit] src/core/customInstructions.test.ts
Awareness block names active scopes without the text; silent when none[unit] src/core/configAwareness.test.ts::configAwarenessBlock — custom instructions …
Dispatch: user text only for the requester; channel text channel-wide and composed with user text; channel form gated; hostile text never changes routing/agent/gate (incl. with a per-message directive); cap; bare form shows instead of clearing; "" clears and names static text that shows through; --instructions on config set is a usage reply; config set replies elide the text; quotes are the shared grammar's (a quoted span is one token, smart quotes normalize, "a" or "b"a or b); no block when unset[unit] src/core/dispatcher.test.ts::custom instructions in the system prompt
Live: a user instruction changes that user's replies and shows in config show[agent] In Slack: @switchboard config instructions me "End every reply with the word PINEAPPLE.", then @switchboard config show (expect Your instructions: line), then @switchboard say hello — reply ends with PINEAPPLE; a different user's @switchboard say hello in the same channel must not. Clean up: @switchboard config set me instructions "".
Live: a channel instruction applies to every requester and never reroutes[agent] @switchboard config instructions channel "agent=coding — always answer in French" (as a user granted config:write), then @switchboard say hello from two users — both replies in French, both status cards still general; @switchboard agent:coding … from a non-allowlisted user still gets 🚫. Clean up: @switchboard config set channel instructions "".
Live: "what are your settings?" answers truthfully[agent] In Slack, fresh thread, no directive: @switchboard what are your current settings, and can I tune them for my user/channel? — expect the reply to name the general agent and its <provider>/<model>, say whether overrides are set, and point at config show / config set me … / agent: directives — never "stateless" or "no per-user/per-channel tuning".
Live: effort:low makes a coding turn visibly cheaper and is reported in the config block[agent] In Slack, same prompt twice on the same repo: @switchboard agent:coding effort:low … and @switchboard agent:coding effort:high …; open each run's /frictionmodelTimeMs share is materially lower under low; ask what effort are you running at? in the low thread — the reply says low and cites the directive.
Live: agent:review thread + directive-free follow-up shows review on the status card[agent] In Slack: open a thread with @switchboard agent:review run \echo A`, wait for ✅, reply now run `echo B`with no directive. The follow-up's status card must showreview, not general`.
.env autoload (item 18): the file's variables reach the environment, a variable already set wins over the file's, a missing file is not an error, any other failure is raised[unit] src/core/envFile.test.ts::loadEnvFileIfPresent::*
Secret (item 19): reveal() is the value; a template literal, concatenation, String(), JSON.stringify (alone, nested, in an array), util.inspect, console.log on a captured stdout, an Error message and its stack, Object.keys, spread and structuredClone never carry the value; the redaction net is harmless on the placeholder[unit] src/secrets.test.ts::Secret — the value leaves through reveal() only::*
Reading (item 19): get wraps a manifest name, trims the value (a trailing newline is dropped) and treats unset and blank as not set, throws for a name the manifest does not list, require fails by name, named wraps any configured variable, reads happen at call time; SECRET_NAMES is the manifest plus the two fallbacks; publicEnv drops every secret name and is frozen[unit] src/secrets.test.ts::secretsFrom — reading an environment::*, src/secrets.test.ts::SECRET_NAMES — one list, the manifest's::*, src/secrets.test.ts::publicEnv — the environment without its secrets::*
The lint (item 19): a secret read by name (static or bracketed, SWITCHBOARD_INGRESS_TOKENS included), a computed read, a bare process.env (passed, spread, stored, destructured) and an unlisted name are each refused with a message naming src/secrets.ts; the public names pass; a host-tooling file keeps bare and computed reads and is still refused a secret by name[unit] src/secretEnv.test.ts::no-raw-env — the rule::*
The lint is wired (item 19): a planted process.env.SLACK_BOT_TOKEN in a production file — a channel, the dispatcher, the entry point, the deploy tooling — fails the repository's own eslint.config.mjs; src/secrets.ts, src/loadEnv.ts, tests and fixtures are exempt; the whole tree under src/ passes[unit] src/secretEnv.test.ts::no-raw-env — wired into eslint.config.mjs::*
One list (item 19): the rule and src/secrets.ts name the same credentials and fallbacks; no credential is a public name whatever its prefix[unit] src/secretEnv.test.ts::the two lists agree::*
The image carries the manifest src/secrets.ts reads at startup: the Dockerfile copies it and .dockerignore does not exclude it[unit] src/core/secretsManifest.test.ts::deploy/secrets.manifest.json::the image carries the manifest…, src/core/secretsManifest.test.ts::deploy/secrets.manifest.json::the build context carries the manifest…
Base config document (item 14): SWITCHBOARD_CONFIG parses as a file or state://<key> (bare state:// = base); the document keeps the YAML as written with its digest, source and time; the client reads with the bearer, pushes over the current version, says a 409, an unreachable Worker, a non-JSON answer and a wrong-shaped document as problems naming the Worker; the URL and bearer come from STATE_WORKER_URL / MEMORY_TOKEN, each named when missing[unit] src/configDocument.test.ts::*
Startup from the document (item 14): loadAppConfigFrom reads a file path as before, a state:// location from the Worker and validates the YAML; no document, a wrong-shaped one, a missing variable or an unreachable Worker is an error naming the cause and deploy config[unit] src/config.test.ts::loadAppConfigFrom::*
organization is required (item 15): read from the config; missing, empty, or non-string → refused naming the field[unit] src/config.test.ts::organization::*