Settings page
GET /settings is the dashboard section where a customer configures what Switchboard already lets them configure, and sees what only config.yaml can change. It is an adapter over the command registry and nothing more (record 0041): every tab's seed is the answer of registry commands invoked as the viewer, every write the page makes is one POST /api/<group>.<verb> (command-registry.md, dashboard-routes.md), and the page carries no rule of its own about who may do what. Registry sections: MCP servers (mcp list as a table, an add form, a connect link, a remove button; mcp-tools.md items 13 to 19), Channels (the index of configured channels, then one channel's scope as a form over config show|set|clear|instructions channel; routing-and-config.md items 5, 12, 24) and Installation (a read-only projection of the running config.yaml and the capabilities that are on; capabilities.md).
The dashboard configures shared org/channel tiers and the actual account's own tier. A signed Access account uses its access:<sub> scope and current grants; email matches and explicit conversation links do not lend Slack ownership or writes. A service token has no human me scope. The policy and command handlers decide every write; the page displays their answers.
- Code:
src/channels/settingsView.ts(parseSettingsRoute,createSettingsViewHandler,channelScopeView,homeTab),src/core/names.ts(NameDirectory,namesOf: the names beside ids),src/channels/slackNames.ts(slackNames: the Slack directory),src/core/installationSettings.ts(installationSettings: the allow-list projection),src/channels/webSeed.ts(SettingsSeed),web/green/pages/Settings.vue,web/src/components/ChannelPicker.vue(the channel combobox),web/src/components/SettingSelect.vue(the one select),web/src/components/settings/(McpServersPanel.vue,ChannelsPanel.vue,InstallationPanel.vue),web/src/lib/settingsApi.ts(postCommand,getCommand),web/green/pages/Settings.vue(current Settings sections and unavailable/Reload state),web/green/components/Shell.vue(desktop sidebar and phone drawer Settings link); the wiring insrc/index.tsbeside the other dashboard views; the preview fixtures inscripts/web-preview.ts. - Code:
src/core/dispatch/spawnConfig.tsholds the pure spawn limit and default shown on Installation;spawn.tsre-exports them for dispatch callers. - Tests:
web/src/lib/commandConfirmation.test.ts,src/channels/settingsView.test.ts,src/channels/slackNames.test.ts,src/core/installationSettings.test.ts,web/src/pages/settings.test.ts,web/src/components/AppNav.test.ts(the nav's Settings section); the command halves insrc/core/commands/config.test.tsandsrc/core/commands/mcp.test.ts. - Docs: record 0041; dashboard-routes.md; Connect an MCP server, Configure your defaults; live-view.md item 18 (the nav); AGENTS.md invariant 3 (adapters resolve identity, never authority).
Behavior
Routes. The registry-settings adapter handles
/settings,/settings/mcps(with?channel=<id>),/settings/channels,/settings/channels/<channel id>and/settings/installation./settingsopens on MCP servers when themcpcapability is on, else Channels. Other paths fall through to their own handlers: the replacement also has Identities and Settings → Runtime. The shared dashboard gate protects each route. A method other than GET is405; a throwing registry is502with a capped reason.The seed is the registry's answer under the actual account. The production browser uses the freshly resolved native Access actor and that account's current grants. Its
mesettings and MCP tier remainaccess:<sub>, including when an email matches a Slack account or the accounts are explicitly linked. Email is display data; explicit links correlate future conversation projection, not Settings ownership,selfaliases or borrowed Slack write rights. Person view is read-only; global work-view selection does not lend another person's command authority. MCP rows come frommcp list(with--channelwhen named); channel rows fromconfig overridesandconfig show --channel. The bound registry receives the same actual caller as/api. A refused or unavailable command puts its sentence on the seed instead of rows.channelScopeViewremoves the viewer's user scope and everymcpServersmap; server views carry no credential names. The retained legacy caller fixtures below exercise explicitly injected caller metadata only; they do not prove production email-to-Slack resolution.Installation is a projection by allow-list.
installationSettings(config, capabilities)writes every row out by name (row(key, configured, default, how, note)), the legacy projection iteratesdefaults.modelsanddefaults.efforts, whose keys are preset names. The DSL projection uses the registered names and shared resolver to exposeagents.<name>.profile, model and reasoning effort; each row carries the configured value or the default markedisDefault, andhowsays whether a channel overrides it at run time (runtime, agent model and effort defaults) orconfig.yamlwithdeploy configand a restart changes it (config). The capability list names each capability's block in words, never an env var. The rendered view holds no*Envname, no URL, noSHOUTING_CASEidentifier and no name from the deploy secrets manifest, whatever the config holds. The tab is gated onconfig:read.canWriteis the handler's question, asked early. The seed carriescanWrite.org = authorize(actor, "mcp:write", config-scope org),canWrite.channel = authorize(actor, "mcp:write", config-scope channel)(false without a channel) on the MCPs tab andauthorize(actor, "config:write", config-scope channel)on an open channel: the same calls themcp.*andconfig.*handlers make. The page uses them to disable controls and say why; it decides nothing, and a write forced through anyway meets the handler's refusal.Every write is one command. Add posts
mcp.addwithname,url,scope(me— the viewer's own tier, defaulted when no channel is open — ororg, orchannelwithchannel),agentsjoined by commas andauthwhen chosen; Connect postsmcp.connectand Remove (after a confirm) postsmcp.remove, each naming the row's tier; Probe readsmcp.show. Save postsconfig.setwithscope: channel,channeland only the filled fields (aconfig setpatches; an empty field leaves the stored value alone); the instructions box postsconfig.instructionswith its text, an empty text clearing them; Clear (after a confirm) postsconfig.clear. A connect link comes back fromaddandconnectand is shown as a link to the Access-gated connect page; a credential never passes through this page. A held write displays its exact saved action and risk through the shared web API confirmation adapter. Confirm or Cancel sends only the saved id to the same command route; the core atomically claims it, rechecks current permissions and returns the original command JSON. Token and loopback dashboards use the same affordance without an Access approval link. A rejected or cancelled action changes nothing.Global Settings navigation and capability sections. The replacement desktop sidebar and phone navigation drawer both link to
/settings; the account menu links directly to Identities. Settings sections are Channels, MCP servers, Identities, Runtime and Installation. MCP servers appear only when configured or already selected; Runtime appears when resident environments are configured. Channels, Identities and Installation remain available. The selected section is marked current. A missing or mismatched seed shows Settings unavailable with a Reload button and no editable controls; Reload requests the current page again through the same gate.Channels tab. Never "no data" (record 0041, accepted): the tab opens on the viewer's own settings — what a run they ask for outside any channel gets (
config showwithout a channel: the installation defaults under their own scope,ViewerSettingsView), the defaults, and their scope or the sentence that nothing is theirs yet; a refusal of that read is shown as is beside the index. The index lists each channel that carries a scope with the names of its settings and where they come from (config.yaml,runtime, both), as links to the channel's page; an empty index says every channel runs on the defaults, and a picker opens any channel the viewer may read, by name: a combobox (ChannelPicker.vueover Nuxt UI'sUInputMenu) — type a few letters of the name or the id, the list narrows, Enter or a click opens the channel at once (config channels→ the seed'spickable:#namewith the id under it, a private one marked; the bot's own channels plus the scoped ones, and the open channel even when the list lacks it); an id field stands in only when nothing could be listed; a note says when the bot could not list its channels and only the scoped ones are offered. Every other choice on the page (agent, effort, identity, tier, auth) is the same styled select (SettingSelect.vueoverUSelect, the empty "leave it" choice spelled out), never the browser's. Names, not ids (record 0042, the dashboard reads names): the seed carries each channel's name without its hash beside its id (channelName, from the core'sNameDirectory—src/core/names.ts, the Slack directorysrc/channels/slackNames.tsover the adapter's cachedconversations.infoandusers.infolookups; the static one knows nothing), asked once per channel, concurrently, a failure or an unknown id leaving the id alone; the index rows, the open channel's heading and a refusal line read#namewith the id in the tooltip (and beside the heading), and the id alone when there is no name. A missing or mismatched seed shows an unavailable error and Reload, with no editable settings. The open channel shows the effective agent, model and effort for a run there, the form over its scope (agent, model, per-agent models, effort, per-agent efforts, the boundary's three axes, instructions), the restricted agents marked, and the admins hint when the viewer may not write.MCPs tab. Rows show name, tier (a user row names its owner), URL (origin and path only, as
McpServerViewgives it), agents, auth, state (connected,awaiting credential,pinned in config.yaml), an Added by column that readsyoufor the viewer's own — the person's display name when the service resolved one, the id otherwise (record 0042); Connect appears on a runtime row whose auth is notnone, Remove on any runtime row, both disabled without the tier's write right; a static row has neither. Which rows (record 0042): a viewer with the org write right sees every tier there is (the seed ismcp list --all,allTiers: true) and a Promote button on every person's runtime row that is not already shadowed, which asks first and postsmcp.promotewith the row's name and owner, then shows the org's connect link (or reloads whenauth: noneconnected at once). Reading the table: names, not ids — a user row's tier names its owner andpromoted fromnames the person, through the view'sownerName/promotedFromName; the agents are the run page's colored agent chips (AGENT_HUE); a row a higher tier shadows carries ashadowed by <tier>mark and is dimmed; the action column has four fixed slots so buttons line up down the page. The connect link opens in a new tab, and while a link is out the page watches its row (onemcp listevery few seconds, bounded by the link's ten-minute life) and reloads when the row's state changes — so closing the connect tab lands back on a current list; anyone else sees the org's, the open channel's and their own, plus the channel tiers of every channel whose config they may read —config overridesnames them, onemcp list --channeleach; a public channel, a granted one, or a private channel the current actual caller is permitted to read (authorization.md item 7) — and no Promote. The add form offers the org tier and the channel tier — the open channel's when one is open, else a picker of the channels the viewer may read appears in the form itself (config channels, the samepickable) and the add posts the picked channel; the channel field above the table is the same combobox — picking a channel opens its tier, the open channel stays offered even when the list lacks it, a Clear returns to the org's and the viewer's own, and an id field stands in only when nothing could be listed; Add stays disabled for the channel tier until a channel is open or picked, the picked channel's write right being the handler's to decide. A channel tier's agents are limited to the self-serve presets. The open channel (?channel=) and every channel-tier row read#namewhen the directory knows the channel (channelNameon the seed and on the row, the service'sresolveChannelNamebeing the same directory), the id otherwise and always in the tooltip. While an admin views the dashboard as a person (record 0053; authorization.md item 17) both tabs show what that person's session would and every write — Add, Remove, Save, Clear — is refused by the registry door with the one sentence naming the person, which the panel shows as its notice like any other refusal; the banner above the page says so before a click.
Validation criteria
| Criterion | Proof |
|---|---|
| Production settings resolve the opaque Access subject without email correlation; explicit links preserve actual native rights and never lend a peer's grants. | [unit] src/channels/browserHomeWiring.test.ts::browser production wiring::uses the verified opaque subject without correlating an email and stamps global scope on every green seed, src/channels/organizationAuthentication.test.ts::current organization authentication::uses verified browser issuer membership and never turns email into an ownership alias |
| Native dashboard writes display saved confirmation, return original results, cancel without effects and refuse replay without requiring an Access link | [unit] web/src/lib/commandConfirmation.test.ts::native dashboard confirmation::native dashboard confirmation works without Access links and returns the original JSON result once |
1: the registry-settings adapter, its three sections, a channel under Channels and a channel query on MCPs parse; an unknown tab, a channel under the wrong tab, a malformed id and anything else are not routes; the home tab follows the mcp capability | [unit] src/channels/settingsView.test.ts::parseSettingsRoute::* |
1: other paths fall through; a non-GET is 405 with allow: GET; /settings opens on MCPs or Channels by capability; a throwing registry is a 502 with a capped reason; a page sender that throws is one 502 with the reason; a response that fails after the 200 was written is closed with no second head and no unhandled rejection | [unit] src/channels/settingsView.test.ts::the settings view::falls through for other paths and answers 405 to anything but GET, src/channels/settingsView.test.ts::the settings view::/settings opens on MCPs where the capability is on, else on Channels, src/channels/settingsView.test.ts::the settings view::a registry that throws is a 502 with a capped reason, never a 500, src/channels/settingsView.test.ts::the settings view::a page sender that throws before any head is one 502 with the reason, never a second head or an unhandled rejection, src/channels/settingsView.test.ts::the settings view::a response that fails after the 200 was written is closed, never given a second head |
2, 4: the MCPs seed is mcp list invoked as the viewer with the channel named; canWrite is the org and channel questions, false for an ungranted viewer and false for the channel without one; a refusing mcp list puts its sentence on the seed | [unit] src/channels/settingsView.test.ts::the settings view::MCPs: the seed is \mcp list` invoked as the viewer, with canWrite from the org and channel questions, src/channels/settingsView.test.ts::the settings view::MCPs: a viewer without the grants sees the same rows read-only; without a channel the channel right is false, src/channels/settingsView.test.ts::the settings view::MCPs: `mcp list` refusing (MCP off, no read) puts its sentence on the seed in place of rows` |
7, 8: the picker — both tabs carry pickable from config channels; a refused or failing config channels offers nothing and fails no tab | [unit] src/channels/settingsView.test.ts::the settings view::Channels and MCPs carry the channels the viewer may pick (config channels) as pickable, on both tabs; a refused or failing config channels offers nothing and fails no tab |
7, 8: names beside ids — the Channels index rows, the selected channel and the MCPs tab's open channel carry channelName when the directory answers; an unknown or failing lookup and a missing directory leave the seed exactly as before; the Slack directory names a slack:U person and a slack:C/G channel through the adapter's lookups, and answers undefined for a DM, a non-Slack id, an unknown id and a failure; namesOf asks once per distinct id | [unit] src/channels/settingsView.test.ts::the settings view::Channels and MCPs name their channels: the index rows, the selected channel and the MCPs tab's channel carry channelName when the directory answers; an unknown or failing lookup leaves the id alone, src/channels/slackNames.test.ts::slackNames — the Slack NameDirectory::* |
2, 4: the Channels seed is config overrides plus config show --channel for the open channel, stripped of the viewer scope and every mcpServers; a refused show carries the refusal beside the id; canWrite is the channel's config:write question | [unit] src/channels/settingsView.test.ts::channelScopeView::drops the viewer's user scope and every mcpServers map, keeps the rest, src/channels/settingsView.test.ts::the settings view::Channels: the index is \config overrides`; a selected channel is `config show --channel` stripped of the viewer scope and mcpServers, with its own canWrite, src/channels/settingsView.test.ts::the settings view::Channels: a refused `config show` carries the refusal beside the channel id, and a member's canWrite is false` |
3: the Installation seed is the projection for a viewer holding config:read and a 403 without it, and no command is invoked | [unit] src/channels/settingsView.test.ts::the settings view::Installation: the projection, for a viewer holding config:read; 403 without it |
3: every named knob renders its configured value or its default marked as such; the minimal installation is all defaults with every optional capability off; the rendered view holds no *Env name, URL, shouting-case identifier or manifest secret name for a config that fills every block; the projection is a function of its two inputs | [unit] src/core/installationSettings.test.ts::installationSettings::* |
| 6: Settings remains a global path; MCP servers are offered only when configured or already selected | [unit] web/src/components/AppNav.test.ts::Global shell navigation::removes unavailable report and schedule paths while keeping Home, run details, Settings, and docs, web/src/pages/settings.test.ts::Settings sections::offers MCP servers only when configured or already selected |
| 6, 3: the page paints the seed's section and marks it current; missing/mismatched seed shows unavailable with Reload and no editable controls; the Installation tab renders one row per knob with the default mark and each capability's state | [unit] web/src/pages/settings.test.ts::SettingsPage::* |
8, 5: rows list every tier with state, agents and the viewer's mark, a static row without Remove; Add posts mcp.add with the tier picked and the agents joined; the channel tier names the channel and a refusal is shown as is; without the shared rights the form is disabled for org and channel and says why, the viewer's own tier stays open; Remove asks first and posts mcp.remove, Connect posts mcp.connect and shows the link; MCP off shows the reason and no form; Added by names the person, the id, or you; Promote (org right only, a person's runtime row) asks first, posts mcp.promote with the owner and shows the org's link; a shadowed row is marked and has no Promote; names stand in for ids; agents are the run page's chips; the connect link opens in a new tab and the page reloads when the watched row changes state | [unit] web/src/pages/settings.test.ts::McpServersPanel::* |
8 (record 0042), the rows the seed carries: an admin's seed is mcp list --all with allTiers; a viewer without org rights gets the org's, the open channel's and their own plus the channel tiers config overrides lets them read, one mcp list --channel each | [unit] src/channels/settingsView.test.ts::the settings view::MCPs (record 0042): a viewer without org rights lists the org's, the open channel's and their own, plus the channel tiers of every channel whose config they may read — config overrides decides which, one mcp list per channel, src/channels/settingsView.test.ts::the settings view::MCPs: a viewer without the grants sees the same rows read-only; without a channel the channel right is false |
| 7: the viewer's own settings lead the tab — what a run gets, the defaults, their scope or the sentence that nothing is theirs yet; a refused read is shown as is; an empty index says every channel runs on the defaults; a missing or mismatched seed shows settings unavailable, exposes no editable controls and offers Reload | [unit] web/src/pages/settings.test.ts::ChannelsPanel::shows the viewer's own settings first — what a run gets, the defaults, their own scope or the sentence that nothing is theirs yet; a refusal is shown as is (record 0041: never a 'no data' state), web/src/pages/settings.test.ts::SettingsPage::without a seed keeps settings unavailable and exposes no editable controls, src/channels/settingsView.test.ts::the settings view::Channels: a refused config show for the viewer's own settings puts its sentence beside the index, never an empty tab |
7, 5: the index links each configured channel with its setting names and source; the open channel's scope fills the form and Save posts config.set with the channel and only the filled fields; instructions save and clear through config.instructions, Clear asks first and posts config.clear, and no body carries me; read-only disables every control and shows the hint, a refused channel shows the refusal; a refusal on Save is shown as is with no reload; the open field navigates to the channel's page | [unit] web/src/pages/settings.test.ts::ChannelsPanel::* |
Retained legacy caller compatibility (record 0042), not production linking: an explicitly injected caller's asUser selects the person's me — `config set | instructions |
The actual Access account's me is its own (record 0043), regardless of conversation linking: `config set | instructions |
| Record 0053: a write from a session viewing as a person is refused by the registry door in the one sentence, which the panels show as they show every refusal | [unit] src/core/commandRegistry.test.ts::CommandRegistry.invoke — auth before parse::a caller viewing as a person (record 0053) runs a read under the person's grants and is refused every non-read effect — even one the person's own grants allow — in the one sentence, decided by the registry before parse, with the audit line naming the person and the reason viewing, web/src/pages/settings.test.ts::*::a refusal on Save is shown as is, and nothing reloads |
Human-gated: on a deployed installation an admin adds, connects and removes an org server from /settings/mcps, sets and clears a channel's agent from /settings/channels/<id>, and an ungranted viewer sees both tabs read-only with the same rows | [agent] Open the three tabs as an admin and as a member; post the writes above; read mcp list and config show --channel in chat afterwards and compare. Receipt on the PR. |