Skip to content

Code map

Where each part of OpenSwitchboard lives, what it owns, and the rule that keeps it that way. This is the map for someone changing the code; the pipeline it implements is explained in How a request flows, and the behavioral contract each area must keep is its spec under docs/reference/specs/.

Areas

The map at a glance: each area of the product, where it lives, and the spec that states what it must do.

AreaScopePathSpec
Orchestration: directives, resolution, gates, history, the run — one entry point over a pipeline of stagesdispatcher, coresrc/core/dispatcher.ts, src/core/dispatch/routing-and-config.md, run-loop.md
Configuration: the schema, the layers a message resolves through, runtime overrides and their validationconfigsrc/config.ts, src/config/routing-and-config.md
Commands once, every surface (chat, CLI, HTTP, MCP)commands, cli, init, setupsrc/core/commandRegistry.ts, commands/, commandSurface.tscommand-registry.md
Authorization: actors, grants, the policy table, predicatesauthzsrc/core/authz/authorization.md
Runs: live registry, history, tracing, the run pageruns, tracing, costssrc/core/runRegistry.ts, runStore.ts, runsService.ts, trace/, src/channels/liveView.tsrun-history.md, live-view.md, tracing.md
Channels: Slack (transport only), HTTP, MCP ingressslack, http, mcpsrc/channels/slack-channel.md, http-ingress.md, mcp-ingress.md
Agents (data), providers, executorsagents, review, coding, ship, research, general, providers, resident, sandboxsrc/agents/, src/providers/, src/execution/agent-*.md, execution.md, resident-repos.md
Memory, skills, MCP tools, GitHub toolsmemory, skills, toolssrc/core/memory/, src/skills/, src/mcp/, src/tools/memory.md, skills.md, mcp-tools.md, github-tools.md
The dashboard (Vue) served from the bot's seedwebweb/live-view.md
The runtime Workers and the project's docs siteworkersdeploy/cloudflare*/release-and-deploy.md, docs-site.md
Deploy selection, order, and live gatedeploysrc/deploy/release-and-deploy.md
Human docs and their generated tablesdocsdocs/, src/docs/docs-site.md
The repository's own process: CI, the checks, releases, dependency updates — deps and main are the bots' scopes (Dependabot's chore(deps) and ci(deps), release-please's chore(main): release …)process, release, deps, main.github/, scripts/, release-please-config.json, CONTRIBUTING.mdrelease-and-deploy.md

Modules

Paths are repository-relative. A row's Notes column is the invariant or the gotcha a change in that area has to respect.

PathWhatNotes
src/core/types.tsChannel contract (IncomingMessage, ChannelIO, StatusHandle, HistoryItem)The open-closed seam for platforms
src/core/llmOutput/Typed LLM output contract: OutputType<T> seam + acceptOutput control loop, markdown canonicalization, JSON typeThe answer is canonicalized once at the dispatcher boundary; raw+canonical on the answer event. See docs/reference/specs/llm-output.md
src/core/dispatcher.tsAll orchestration: the ONE chat fast path (the registry's chat adapter — every command, help included; commands that do work are inline runs), the natural-language op translation into repo.test|build, directives, resolution, permissions, history assembly, agent runThe only place these live; the registry's chat adapter is the only chat parser
src/core/dispatch/The dispatcher's stages as files, one per stage of the pipeline dispatch() runs (admission → resolve → authorize → provision → run → reply → record → settle; decision records 0024 for the method and 0025 for the as-built table and the 800-line cap): reply.ts — how a run is shown (composeRunLabel, humanizeMessageText, activityLine, cardLines, liveViewLink, errorReply, replyCommandOutput, STATUS_PREFIXES); record.ts — what a run leaves behind (assembleRunRecord, the drain's interruptedRunRecord/writeAbandonedRunRecords, the reclaim's reclaimedRunRecord, the channelVisibilityOf stamp over RecordDeps); fastPath.ts — the admission stage's fast paths, what answers before any model turn (answerChatCommand = stage A, the inline command runs, answerOperation = the natural-language op translation, over FastPathDeps); admission.ts — who may hold the thread (admit and its outcome union — proceed, redispatch, steered, refused, superseded — then adoptCarriedRun and foldCarriedInbox for a resumed or restarted run, over AdmissionDeps); resolve.ts — what the request is (readRequest, resolveRun = the (agent, model, effort) triple through the layers, resolveTarget = the provider and the repo/ref/PR resolution started as a promise, over ResolveDeps); authorize.ts — the gates, each against the RESOLVED actor and agent (authorizeAgent, authorizeRepo, authorizePrHead, authorizeAttachedHead, each a Gate<reason>, over AuthorizeDeps); provision.ts — everything a run needs before its first model turn (startMemoryRead, openAckCard, registerRun = the registry row and the request/meta/context events, reserveRun = the ledger reservation, attachWorkspace and its ask-once refusal, composePrompt, over ProvisionDeps); messages.ts — the conversation as provider turns and the context events' text (buildMessages, turnContent, contextMessageTexts); run.ts — the ledger claim once the prompt exists (claimRun), the tools' process-wide capabilities (webCapability, githubCapabilityFor) and the shutdown notice every live card shows (setShutdownNotice, DEPLOY_RESTART_NOTICE), over RunDeps; runLoop.ts — the model turn and everything that rides on it (runLoop: the card frame, the follow-up inbox, the reviewed-head settle, the workspace observation, the description turn, the coding PR post-step, the answer published, the finish); reply.ts also carries the answer's delivery (deliverAnswer: finishing on the ledger, the card close, the reply, the seal, the workspace release) and what follows it (afterReply: the memory reflection, the review post-step) over ReplyDeps; record.ts also carries the tombstone written when the loop takes the run (writeTombstone) and the finish record registered for the drain (registerFinishRecord); ship.ts — the agent:ship fork (runShipBranch: the preflight refusals, the one run record and card around runShipPipeline's round loop, the report; over ShipDeps, the run and reply slices plus the three GitHub seams only ship uses); settle.ts — what happens to the thread when the request is over (settleThread: the slot freed, the unconsumed follow-ups dropped with a note after a stop or handed on; prepareFreshTurn: the one fresh turn they run as, which dispatch() runs). dispatch() keeps the request's shell: the root, the refusal wrap, the stage calls, the outer catch and finallyA stage file exports its functions and the slice of CoreDeps it reads (RecordDeps, FastPathDeps, AdmissionDeps, ResolveDeps, AuthorizeDeps, ProvisionDeps, RunDeps, ReplyDeps, ShipDeps; CoreDeps extends every slice), never the whole bag. A stage hands back what it takes hold of (the thread slot, a ledger row) before the next step that can throw, so dispatch()'s outer finally releases exactly what the inline code did. Tests move with the code: a stage's own tests sit beside it; a behavior proven through dispatch() stays in dispatcher.test.ts
src/core/shipPipeline.ts, src/core/ship/The agent:ship pipeline (docs/reference/specs/agent-ship.md), split the same way: shipPipeline.ts keeps the ship caps block, the interrupted-pipeline note, the card's round header and runShipPipeline — the strictly serial round loop, its cap/stop/abort/merge-ready endings and the ship_round boundaries; ship/preflight.ts — every refusal before round 0 and the entry the loop starts from (shipPreflight, shipTaskText, shipBranchName, ShipEntry); ship/codingChild.ts — one coding round, round 0 and every fix round (runShipCodingChild over CodingChildDeps, its CodingRoundResult, shipBranchContract, buildShipFixTurn); ship/reviewChild.ts — one pinned-head review round (runShipReviewChild over ReviewChildDeps, its ReviewRoundResult, buildShipReviewTurn); ship/childRound.ts — what the child rounds share (ShipChildSpec, ShipBlocks, ChildRoundDeps, ChildRoundContext)A stage exports its function, its outcome type and the slice of the pipeline's input it reads; ShipPipelineInput extends every slice and ShipGithub every GitHub slice. The dispatcher's ship branch (src/core/dispatch/ship.ts) imports the preflight and the child-round types from their stage files; shipPipeline.ts re-exports nothing
src/core/configAwareness.ts, src/core/customInstructions.tsPure system-prompt blocks the dispatcher folds in after resolution + gates: resolved-config awareness and per-scope custom instructions (Scope.instructions)Advisory prompt content only — resolve()/gates never read instructions; see docs/reference/specs/routing-and-config.md items 8–9
src/core/repoContext.tsPre-model repo/ref resolution (slug/URL/PR in the message, thread history)Feeds resident selection; PR→ref via one REST call, never gh
src/core/residentAdmin.ts, src/core/operations.tsThe resident Worker's admin client (makeResidentAdminClient, residentAdminFromConfig) + the slug/ref validators every repo surface shares; the Operations seam (resident /op or local) + recognizeOperation (natural-language op forms only)The repo.* registry commands (src/core/commands/repo.ts) are the callers: repo list (repo:read, every Slack user's baseline), onboard/offboard/reconfigure/rebuild (repo:write — the repo-management right, fail-closed), test/build (repo:exec decided on agent { coding }: the right to run the coding agent or the exec grant; canUseRepo inside)
src/core/runRegistry.ts, src/core/runRegistry/In-memory run registry — the ONE per-run event store while a run is live: per-run id+token+RunMeta, seq-stamped events including the narrative input/context/assistant/answer events that carry the exchange, one backlog bounded by count (8000) and bytes (4 MiB), TTL eviction; access is capability (constant-time token gate) OR operator (token-free getById/snapshotById/requestStopById for RunsService); per-run RunControl (soft/hard stop). The registry's parts as sibling modules under runRegistry/: runControl.tsRunControl; activity.tsactivityOf/activityOfEvents, the one-line activity rule the live summary and the persisted record share; state.tsRunState (the row a live run occupies; the other parts read slices of it, only the registry writes it), RunMeta, the per-run subscriber contract; projections.ts — the read shapes (RunSummary, RunSnapshot, SealResult) and the pure RunState → shape functions listActive, the index feed and the operator reads all go through; backlog.ts — the bounded per-run backlog (count + bytes, the protected head) and the budgeted replay window, writer and reader of one seq-ascending buffer over the RunState slice they name; indexFeed.tsIndexFeed, the runs-index feed (IndexEvent/IndexSubscriber, replay of the active set on subscribe, isolated fan-out) the registry notifies from every lifecycle step; testing.ts — the shared test fixturesBacks the live-view page and, via the finish-time snapshot, the friction diagnosis and the persisted run record; defaultRunRegistry singleton shared with the dispatcher; create() redacts the label — RunHandle.label is the only label a record may carry
src/core/authz/One authorization model (decision record 0007, docs/reference/specs/authorization.md): types.ts is the shared contract (Actor with kind + platform-namespaced id + Grants + onBehalfOf, typed Resources incl. the three config-scope tiers channel | user | org, the CLOSED Condition vocabulary has-grant | member-of | is-self | owner-of | all-channels, Rule, Decision, Predicate, ChannelDirectory); policy.ts is THE table — every gate as data rows (rows for one action + resource target OR, conditions AND, no row → deny) with validatePolicy run at import (a condition outside the vocabulary, or one whose attribute the target cannot carry, fails the module load); every COMMAND is a row <action> command [has-grant(<action>)] (plus config:write command for any user, repo:exec agent for the deterministic ops, mcp:write config-scope/{channel,org} for the MCP tiers) and CommandRegistry.invoke asks authorize(caller.actor, cmd.action, resource) on every surface; authorize.ts (also effectiveGrants = the on-behalf-of intersection) is the one point decision; predicateFor(actor, action, type[, kind]) (predicate.ts) compiles the SAME rows to a store predicate (all | none | channels-in | user-is | repos-in | visibility-in | or | andmember-of is or(channels-in, visibility-in [public]), rows OR into one flat disjunction, and only when one row carries several relations; no POLICY row does today) with matchesPredicate as the reference evaluator; resource.ts = the attributes each target carries; channelDirectory.ts = the ChannelDirectory seam and its static id mapping (visibilityOf: http:/mcp: → machine, slack:D… → dm, slack:G… → private, else unknown — the dispatcher's default; the bot swaps in SlackChannelDirectory, src/channels/slackChannelDirectory.ts, which asks conversations.info for slack:C…/G…, cached per channel per TTL, unknown on any failure) that dispatch stamps every run with, awaited for at most CHANNEL_DIRECTORY_TIMEOUT_MS (1.5 s → unknown); grants.ts is the grants source — parseGrantsConfig for the grants block, parseRestrictConfig for restrict (agents closed unless agent:run:<name> is held, repos closed unless the repos axis names them; mayRunAgent / mayUseRepo decide, case-insensitively for repos) plus the namespace BASELINES: CHAT_OPEN_ACTIONS + every unrestricted agent for a slack: user (never config:write), browserReadActions (every <group>:read) for an access:<sub> session, nothing for a credential, the schedule registry's declared grants for schedule:<name> ids — an entry adds to a user baseline and replaces a schedule's; grantsTable/grantsIn/namespaceBaseline behind ConfigStore.grantsFor; actor.ts resolveActor(input, grantsFor) — kind + namespaced id per surface, grants by id, origin for chat — and resolveChatActor for a message's already-namespaced user idPure, node-free, no I/O, no clock. Adapters resolve identity, never authority (invariant 3): Caller.actor is the ONLY thing authorize reads about a caller — no scopes, no chatGate, no channel pin; a handler's data-dependent refusal asks the table too (config-scope, the repo:write right) and keeps its own reply text. src/core/commands/runs.ts / friction.ts call authorize for point reads (deny → not_found) and hand predicateFor to RunsService / RunStoreFrictionLedger, which push it into the stores as RunListOptions.visibleTo (the wire form RunVisibilityFilter in runRecord.ts; the DO compiles it to indexed SQL) — no handler compares channel ids. Deny reasons are machine tokens with no resource id — on the audit line, never in a reply. has-grant grants may hold a {name} placeholder filled from the resource (agent:run:{name}); <prefix>:* action grants cover the prefix; channel/repo ids are literal. Rows carrying originVisibility (org memory writes) are point-check only → predicateFor gives none. An absent grants axis is the empty set and all is explicit; ids are platform-namespaced (invariant 4). Tests: policy.test.ts enumerates every row (a row without an allow + deny case fails — a new command row needs its cases there), predicate.test.ts is the load-bearing predicate ⇔ authorize differential
src/core/runRecord.tsNode-free run-history contract shared with the state Worker: RunRecord/RunListItem/StoredRunEvent, structural validators + normalizeDiagnosis (a stored record survives new friction categories), the ONE retention function, byte budget, paging limits, storedEventSeqs, cloneImported by both src/core/runStore.ts and deploy/cloudflare-memory/worker.ts — no Node built-ins, no I/O, no clock
src/core/runStore.ts, src/core/runStoreWorker.tsRunStore seam (put/get/getSummary/list/events/delete; eventsnull for an unknown run; getSummary = the record minus events, for reads that need no event set) with InMemoryRunStore, FileRunStore (explicit host-disk opt-in), and WorkerRunStore (HTTPS client to the RunHistoryDO; the production choice); RunHistoryConfig + buildRunStore startup selectionEvery implementation applies the same retention and { before, beforeId } list cursor; events keep the registry seq; see docs/reference/specs/run-history.md
src/core/runHistoryWriter.tsThe dispatcher's write path: fire-and-forget, drain-counted write(record) with bounded jittered retries on transient errors, none on permanent/route-missingRuns AFTER the reply; pending() is what the shutdown drain waits on
src/core/runsService.tsRunsService: the one async service behind every runs.* read/stop and the run page — merges live registry rows and store rows into token-free RunViews (ordered by the store's key, nextBefore cursor), pages events, authorizeLive (sync token check for SSE/HTML)Callers are authorized one layer up; no output ever carries a capability token
src/core/commandRegistry.ts, src/core/commandSurface.ts, src/core/commands/*, src/core/commandCatalogue.ts, src/core/commandChat.tsCommand registry: defineCommand once — id <group>.<verb>, typed positional args + camelCase options (zod, inferred into the handler), action (<group>:read|write|exec), an optional resource(rawInput, caller) resolver (repo.test|buildagent { coding }), effect, per-surface opt-outs, handler — and invoke(id, { args, options }, caller) runs authorize (authorize(caller.actor, cmd.action, resource) over the policy table in src/core/authz/, the SAME question on every surface) → parse → handler → map; commandSurface.ts DERIVES every surface from the definition (--kebab-case CLI/chat flags, group_verb MCP tools, /api/group.verb, the ONE tokenizer+grammar for CLI argv and chat text, namedToInput for by-name JSON, jsonSchemaFor, usage/help); commands/all.ts is the one catalogue — EVERY command: help.show, config.show|set|clear|instructions, runs.*, friction.report|propose|analyze, repo.list|onboard|offboard|reconfigure|rebuild|test|build, memory.list|forget, schedule.list, deploy.plan|all, env.bootstrap (deploy all, env bootstrap, friction analyze are CLI-only) — commandCatalogue.ts (buildCoreCommands, CoreCommandWiring, defaultOperations) the one binding the bot and the CLI share; commandChat.ts is the chat adapter (<group> <verb> <args…> [--flag value…] + the bare word help, THE stage-A fast path in the dispatcher, malformed tail → an invalid_input reply — ONE error vocabulary: a grammar rejection on the CLI or in chat carries the same code the registry gives that fault over HTTP/MCP, only the usage-hint message differs — --help/<group> help derived, Caller.origin = channel + thread + lazy repo)Adapters contain no command logic, no grammar of their own, and NO authorization decision: Caller is what the adapter resolved, never what the request claimed, and its actor (grants from ConfigStore.grantsFor) is the one input the policy table reads; parse errors never echo the value; a failure says who decided it (decidedBy: registry | handler; CommandError may be unauthorized/invalid_input for data-dependent refusals — which ask the table too, on config-scope); one audit line per invoke, never the payload, the table's deny reason on a registry refusal; stored text on machine surfaces is wrapUntrusted; no command starts a run (invariant 3 corollary); every new command must pass the registry-driven conformance suite src/core/commandConformance.test.ts (it enumerates the catalogue and derives every case from the zod schemas AND the policy table — a new command is covered automatically or fails loudly: add a FIELD_HINTS / COMMAND_FIXTURES entry in src/core/testing/commandConformance.ts if the generic fixture cannot drive it, fake any new CoreCommandDeps slice in fakeDeps (src/core/testing/conformanceFixture.ts) with a RECORDING stub — the suite disarms node:child_process and fetch, nothing real ever runs — give its action a row in src/core/authz/policy.ts on the resource it authorizes (with the row's allow + deny cases in policy.test.ts; a command without a row is named by policyGaps and refused for everyone) and, for a chat-open read, list the action in CHAT_OPEN_ACTIONS, add its ## Catalogue row in docs/reference/specs/command-registry.md, and update the snapshot deliberately with vitest -u; npx tsx scripts/command-conformance-matrix.ts prints the suite's scenario matrix — variants × surfaces, then the Authorization table (every command × the fixed actor set) — for the PR body); see docs/reference/specs/command-registry.md
src/channels/dashboardAuth.ts, src/core/dashboardAuthConfig.ts, src/channels/accessAuth.tsDashboard auth as a Strategy (plan D5): one DashboardVerifierverify(req) → identity | refusal — composed once at startup from dashboard.auth: access (the Cloudflare Access JWT re-verified in-process, fail-closed: RS256 pinned, JWKS cached by kid, accessAuth.ts), token (a constant-time bearer from a named env var → one configured actor), none (loopback callers on a localhost deployment only). dashboardAuthConfig.ts is the pure half — the config block's validation and the selection rule (access if ACCESS_* else none) the capabilities value sharesindex.ts asks the verifier once per request before every dashboard route; a strategy missing its inputs is a startup error. See docs/reference/specs/access-gate.md
src/channels/commandHttp.tsHTTP adapter: /api/<group>.<verb> behind the dashboard gate — isCommandPath is the ONE gate predicate index.ts uses, reads GET/POST, writes POST-only + JSON + same-origin, 403 before the body is buffered where the table can decide without the input (CommandRegistry.refuses), the caller resolved as the Actor the policy table decides on — browser access:<sub> (every group's read as the baseline, writes from its grants entry) or service token access:svc:<common_name> (exactly its grants entry) — with grants from ConfigStore.grantsFor, never from the adapter; serviceTokenAllowed — a service token is served /api/* ONLY (never /runs*, /residents*, /costs*), accessActor is the one Access identity → Actor resolver (shared with the /runs pages) and callerIdFor its caller-id formNever emits CORS; every response no-store; carries no reachability rule of its own — the dashboard auth strategy decided before it ran
src/core/runFriction.tsRun-friction analyzer: pure analyzeRunFriction(events) → structured diagnosis of delay causes + text reportAnalysis only, no side effects; surfaced via GET /runs/:id/friction, the registry's runs friction <id>, and the CLI-only friction analyze <capture>; see docs/reference/specs/run-friction.md
src/core/frictionProposals.ts, src/core/frictionLedger.ts, src/core/selfImprovement.tsSelf-improvement proposals: FrictionLedger seam — RunStoreFrictionLedger READS recent runs from run history's listing (store.list, never events; the record carries the diagnosis, nothing is written twice; in-memory double for tests); pure clusterFrictionproposeImprovementsdedupeProposals over the ledger; runSelfImprovement files the top recurring patterns as labeled GitHub issues via the IssueTracker seam (src/execution/githubIssues.ts: REST + in-memory); friction.report / friction.propose are registry commands (src/core/commands/friction.ts) whose --dry-run/--top/--min-runs/--repo flags are the registry's derived grammar on every surfaceProposals only — never PRs, never merges; propose is behind the fail-closed repo-management gate; dedupe key is a marker in the issue body; CLI: npx tsx src/cli.ts friction propose --dry-run; see docs/reference/specs/self-improvement.md
src/core/memory/Cross-session memory: MemoryStore seam, read path (types, keyword+recency scorer, scope derivers — org:<organization> from the config's organization, the bound repo's repo:owner/name, the message's channel:slack:C…, and the requesting user's user:slack:U…, merged into one ranked pool), write path (reflection.ts: post-reply async distillation on memory.model, validated + redacted, audience-routed to the org, repo, channel, or the user's own scope — every write then decided by authorize(runActor, "memory:write", memory-scope{…, originChannelVisibility}) for the run's principal under the run's stamped visibility: a private/DM/unknown origin never writes org and is narrowed (dm → user, else channel → user), never widened, never silently dropped; reads never consult the policy), the shared engine.ts (rank + dedup/supersede plan), and three stores: Null, InMemory, and the durable WorkerMemoryStore (HTTPS client to deploy/cloudflare-memory/). Human controls are the registry commands in src/core/commands/memory.ts: memory list [words…] [--scope me|org|repo|channel|all] [--limit n] [--repo o/n] / memory forget <id> (soft-delete → status: forgotten), caller-scoped on every surface; per-scope cap: memory.maxRecordsPerScope (default 500), planEviction LRU soft-eviction (status: evicted) applied on write by both stores. Flag-gated OFF by defaultNullMemoryStore when disabled → model input byte-identical to memory-off, nothing written; the only user scope a request can read, write, list, or forget is its own (isolation by construction, invariant 4; org/repo/channel forget is admin-gated, fail-closed); buildMemoryStore in index.ts picks the Worker store when memory.worker + its bearer are set, else in-process with a startup warning; reflections are fire-and-forget, awaited only by the drain; see docs/reference/specs/memory.md
deploy/cloudflare-memory/State Worker: memory — one SQLite-backed Durable Object per scopeKey (FTS5 candidate match), POST /retrieve + POST /write (optional cap, evicts LRU inside the write transaction) + POST /list + POST /forget; scheduled firings — one ScheduleDO, POST /schedules/record + POST /schedules/latest; run history — one RunHistoryDO per store key (runs + run_events(run_id, seq) tables, the DO-owned retention policy, a 6 h sweep alarm), `POST /runs/putget
src/skills/Skill loading: SkillStore seam + Bundled/InMemory stores + frontmatter parse; vendored skills under skills/<slug>/SKILL.md from skills/manifest.yaml (manifest.ts; npm run skills:sync / skills:check, scripts/skills-sync.ts). list_skills/use_skill tools in src/tools/skills.tsProgressive-disclosure block appended per agent in the dispatcher; bodies load on demand; per-agent scoping via frontmatter agents. Never hand-edit a vendored SKILL.md — the suite and CI check the tree against the manifest. See docs/reference/specs/skills.md
src/mcp/External MCP servers as agent tools: McpClient seam (StreamableHttpMcpClient over the SSRF-pinned web fetch + InMemoryMcpClient), bridgeMcpTools (one RunnableTool per remote tool, mcp__<server>__<tool>, descriptions + results wrapped as untrusted, sideEffectFree only under readOnlyHint, ONE call budget per run, one mcp.<server>.<tool> span per call), the DiscoveringMcpToolSource engine (per-run discovery, bounded fan-out, in-process tools/list cache, mcp_unavailable outcomes) behind ConfigMcpToolSource (the config layers) and StaticMcpToolSource (a fixed list — tests), CompositeMcpToolSource, mcpGuidanceBlock; registry.ts the node-free contract shared with the Worker (McpServerEntry, tickets, sealed credentials, validators); oauth.ts OAuth 2.1 for auth: oauth servers (item 18 — auth detection, RFC 9728/8414 discovery, RFC 7591 registration, PKCE S256, code exchange, refresh; the connect page's button + GET /mcp/oauth/callback, both Access-gated); secretStore.ts seam (InMemory/File/WorkerMcpSecretStore → the ConfigDO); sealed.ts AES-256-GCM under the bot-only MCP_CREDENTIAL_KEY; connect.ts the pure ticket state machine; service.ts McpService — EVERY rule (org = admins, channel = the config:write grant, user = self-serve; only org reaches coding/review; name uniqueness; verify-then-seal) over the CONFIG STORE; buildMcp in index.ts is the one wiring. Commands: src/core/commands/mcp.ts (`mcp listadd
src/core/schedules.ts, src/core/scheduleStore.ts, src/channels/scheduledPanel.tsScheduled jobs as runs: the schedule registry (SCHEDULES — the one catalog of every cron any Worker runs; each entry names its worker (botresident), its action (run
src/core/ingressTokens.tsThe ONE parser of SWITCHBOARD_INGRESS_TOKENS (node-free)Used by http.ts/mcp.ts and by the shim to find the cron token; an ambiguous subject resolves to nothing
src/channels/liveView.ts, src/channels/markdownLite.ts, src/channels/runTimeline.tsLive-view surface: token-gated GET /runs/:id (the run page: Request → Earlier in this thread → This run: the time summary, then steps of narration + collapsible call cards → Reply, timestamps) + /runs/:id/events (SSE) + POST /runs/:id/stop?mode=soft|hard (run control); the handler keeps ALL routing/auth/SSE semantics and serves the shared web shell with a page seed — rendering lives in web/; markdownLite is the safe-subset markdown renderer and runTimeline the pure grouping/classification model (steps, call↔result pairing by callId, exit/size/duration facts, open-by-default tags) — both self-contained pure modules the web bundle imports straight from src/Capability-URL auth for a LIVE run (the viewer's actor is not consulted — the token IS the capability) + Access at the edge; the index and every tokenless finished-run route are bound to the Access identity's Actor (ctx.actor, resolved in index.ts by accessActor — the same resolver /api/* uses): the index lists through predicateFor(actor, "runs:read", "run") (default rows, ?all=1, the ?stream=1 feed, the Scheduled tab's live links) and a tokenless read is authorized on the run's own attributes, a deny being the same 404 as an unknown id (docs/reference/specs/authorization.md items 5–7); a finished run is served tokenless from RunsService (registry, then the run store) through the same page, seeded with its events — history mode; consumes the run-visibility stream; see docs/reference/specs/live-view.md items 6, 11–16
src/channels/webShell.ts, src/channels/webSeed.ts, src/channels/webAssets.tsThe one HTML document every dashboard route serves (renderShell: <div id="app"> mount + the sb-seed JSON island + hashed /assets/* refs), the typed seed contract per page (serializeSeed \uXXXX-escapes < > & U+2028/29 — hostile text can never close the island), and the immutable asset server (in-memory at startup from SWITCHBOARD_WEB_DIST or web/dist under the package root — the checkout, /app in the image, dist/assets in the npm package — code only, no data)WEB_HTML_HEADERS: strict CSP (script-src 'self' — NO inline JS executes; the seed island is a non-executing data block) + X-Frame-Options: DENY + no-store; a finished row's capability token never reaches a seed; see docs/reference/specs/live-view.md (Rendering paragraph)
web/The web app (its own npm package: Vue 3 + Vite + Nuxt UI v4 on Reka UI + Tailwind v4; vitest + happy-dom + @vue/test-utils): every dashboard page (runs index, run, scheduled, residents, costs, the run 404) renders client-side from the server's seed, live pages attach the SSE feeds; imports the shared pure modules (runTimeline, markdownLite, indexFormat, scheduledPanel model, webSeed types) straight from src/ so folding/formatting has ONE implementationnpm run typecheck / npm test (the rendering proof layer — the row/page behavior tests live here) / npm run buildweb/dist, which src/index.ts loads and the Docker image copies; v-html appears nowhere; CI runs a dedicated web job; npx tsx scripts/web-preview.ts previews over fixtures
docs/, src/docs/, scripts/docs-gen.tsThe human-facing docs (Diataxis: tutorials/how-to/reference/explanation) AND the site built from them in place — docs/ is its own npm package (VitePress; npm run docs:dev / docs:build, output docs/.vitepress/dist, docs/plans/** unpublished, dead links fail the build, mermaid rendered). The mechanical reference tables are GENERATED from the command registry: src/docs/reference.ts renders them, src/docs/regions.ts does the marker surgery, npm run docs:gen writes them, npm run docs:check (CI) fails on driftNever hand-edit between <!-- generated:… --> markers — change the code and re-run docs:gen. A relative .md link inside docs/ must keep working on GitHub, so links OUT of the tree (root README.md, AGENTS.md, docs/reference/specs/*.md) are absolute repo URLs. Note docs/node_modules/ exists after an install — scope repo-wide greps accordingly. See docs/reference/specs/docs-site.md
src/channels/residentsView.ts, src/channels/residentsModel.tsResidents dash: Access-gated GET /residents (index) + /residents/<owner>/<name> (detail) — the browser twin of repo list; the handler serves the web shell with the listing/record as the seed, the pages render in web/src/pages/Residents*.vue; residentsModel holds the slug/live/tone helpers the handler and the page shareReads the resident admin /residents route live per request; never caches, never puts the bearer in a seed; see docs/reference/specs/resident-repos.md item 42
src/core/costs.ts, src/channels/costsView.tsSpend report: pure buildCostReport prices a named group (Workers' DOs + container apps + an Anthropic workspace) from Cloudflare's billing datasets (CloudflareGraphqlUsageSource) and the Anthropic Admin cost report (AnthropicCostReportSource; NullLlmCostSource when no admin key); Access-gated GET /costs, /costs/<group>, /costs/<group>.json — the HTML routes serve the web shell with a CostsSeed, rendered by web/src/pages/CostsPage.vueLive per request, nothing stored; both sources injectable (fetch seam); config costs: validated at startup; see docs/reference/specs/costs.md
src/channels/slack.ts, src/channels/slack/, src/channels/slackChannelDirectory.tsSlack adapter (Bolt, Socket Mode): slack.ts wires the events, the reconnect catch-up and SlackIO; the concerns that stand apart live under slack/attachments.ts (which files reach the model, budgeted downloads, the secret-file denylist), lookups.ts (cached display names, email, team URL, permalink), dedupe.ts (the handled-set and the redelivery guard), statusCard.ts (the card's Block Kit frame and the live-card record the sweep asks); SlackChannelDirectory = the core's ChannelDirectory seam over conversations.info (public / private / dm per channel, TTL-cached and bounded, unknown on any failure, slack:D… and non-Slack ids answered without a call, isMember still unknown), wired by src/index.ts as the dispatcher's channel facts once the Slack app existsTransport only: mention-strip, thread fetch, chunked replies, status edits; the directory supplies facts the policy reads off the run's stamp — never a decision (docs/reference/specs/authorization.md item 7)
src/channels/slackTriggers.ts, src/channels/slackCatchUp.ts, src/channels/slackCatchUpStatus.tsPure trigger gating shared by the live handlers and the reconnect catch-up: on every Socket Mode connected, scan member channels' recent history and re-dispatch mentions/follow-ups with no receipt from us (no 👀, no bot reply after); slackCatchUpStatus is the in-process record of the last scan's outcome + the startup bot-scope check (REQUIRED_BOT_SCOPES, missingBotScopes) that GET /healthz reportsSlack is the dedupe record — never a persisted last-seen ts (invariant 6); 30-min window (slack.catchUp), derived from DRAIN_DEADLINE_MS in src/core/drain.ts; the status record is live-only diagnostics, empty after a restart by design; see docs/reference/specs/slack-channel.md item 7
src/cli.tsThe switchboard CLI — THE operator toolbox, a thin wrapper over the command registry: npx tsx src/cli.ts <group> <verb> [args…] [--option value…] [--json] (also npm run cli -- …; every registered command, words/flags/help DERIVED from the definition — deploy all, env bootstrap, friction analyze exist only here) plus the ONE built-in ask (npx tsx src/cli.ts ask [--thread <key>] "<request>") — the channel harness over dispatch() and the proof of the abstractionCaller cli:local with every scope; bot config comes from SWITCHBOARD_CONFIG (default ./config/config.yaml) and is loaded on first use — deploy.*, env.*, friction analyze, schedule list, help need none, so they run in a worktree / fresh clone / CI; a command that does need it fails unavailable naming the path (exit 1, no stack); a fresh process has no live runs, so it reads persisted history; --json prints the exact invoke JSON (the contract test's CLI row); exit 2 = the invocation was rejected (usage, or invalid_input from the grammar or the registry — one code per fault on every surface), 1 = any other command error; ask is a channel, not a command; there is no standalone script beside the CLI — every operator verb is a registry command
src/agents/registry.tsAgents as data: prompt + toolset + budgetsAdd agents here; give them a default model in config
src/setup/plan.ts, src/setup/host.ts, src/core/commands/setup.ts, docker-entrypoint.shswitchboard init (init.md): the pure planner (answers + the checked-in examples → .env at 600, config.yaml, the profile; the config through the real loader; capabilities; next commands; refusals as values), the host half (templates from the package root, files into the working directory, a prompt only on a TTY), the CLI-only setup.init registration (init is its one-word spelling in src/cli.ts), and the image's entrypoint that runs the CLI when given argumentsDerive from the examples, never hand-write a config; a value the operator gives goes to .env and nowhere else; deploy/profile.json from a checkout's root or the directory the published package runs in, never the image
src/execution/githubApi.ts, src/tools/github.tsThe github_* tools (docs/reference/specs/github-tools.md): GithubApi seam (RestGithubApi on the App token — read-scoped for reads, write-scoped for writes; InMemoryGithubApi) + the ten tools; GithubCapability = the API plus the requesting user's per-repo write gate, built per run by the dispatcher (githubCapabilityFor)REST in the bot process, never gh (invariant 5); reads in every toolset with a tool loop, issue writes in assistant + full only
src/core/selfDescription.tsThe About block every agent's prompt carries (docs/reference/specs/routing-and-config.md item 11): agents from the live registry, residents, runs, where the source + specs liveComposed after the config block; keep it a few lines — it rides on every turn
src/providers/Provider interface, Anthropic + OpenAI-compatible adapters, registryOpenAI-compatible endpoints are config-only additions
src/execution/Executor interface; local, E2B, Cloudflare Sandbox, and resident backends; factoryE2B keys sandboxes via data/sandboxes.json; Cloudflare keys them on X-Thread-Key through the proxy Worker in deploy/cloudflare-sandbox/
src/execution/resident.tsResidentExecutor: attach-on-open client for the resident WorkerWarm-gated selection + named fallback live in factory.ts
src/runner.tsProvider-blind agent loop (complete → run tools → append → repeat)Turn budgets on the agent def; honors RunOptions.control (soft = finale, hard = abort in-flight via AbortSignal); consecutive sideEffectFree tools in one turn run concurrently, mutating ones alone in order (docs/reference/specs/run-loop.md item 9)
src/core/statusCoalescer.tscoalesceStatus(handle, minMs) — at most one status-card edit per interval, newest frame wins, done immediateWraps every run's card in the dispatcher (STATUS_UPDATE_MIN_MS); CoreDeps.statusUpdateMinMs = 0 in tests that assert an intermediate frame
src/core/mapLimit.tsmapLimit(items, n, fn)Promise.all with bounded concurrency, input-ordered resultsThe one fan-out helper; used by the Slack reconnect catch-up (channels ×4, threads ×4)
src/config.ts, src/config/validate.tsLayered config, runtime overrides, the authorization blocks (grants/restrict); the load-time validators in validate.ts (every finding names its key and fails the load — the same rules hold a stored overrides document); the OverridesBacking seam (File / Worker → the state Worker's ConfigDO / InMemory), openConfigStore (the production open: backing chosen from runtimeOverrides.worker, document loaded once)Chat-set overrides persist through the backing — the ConfigDO in prod (invariant 6), data/overrides.json without a Worker; writers are async and roll back on a failed save; the CLI and the bot write ONE document (optimistic version; a 409 rebases the change on the reloaded document, serialized in-process)
src/directives.tsagent:x model:p/m inline parsing
config/config.example.yamlAll config knobs, documentedCopy to config/config.yaml (gitignored)
src/deploy/plan.ts, src/deploy/liveGate.ts, src/deploy/run.ts, src/deploy/operatorRoot.ts, src/deploy/workArea.ts, src/deploy/host.ts, src/deploy/accountRegistry.ts, src/deploy/images.ts, src/deploy/imagesHost.ts, src/deploy/registryTransfer.ts, src/deploy/registryTransferHost.ts, src/deploy/imagePins.ts, src/deploy/buildStamp.tsnpx tsx src/cli.ts deploy all (the registry's deploy.all, CLI-only; deploy plan on every surface) — the production deploy order (memory → bot → resident → sandbox) as a pure, unit-tested plan (WORKERS, planDeploy, classifyDeployOutput), the pure live decision (decideLive, heartbeatLine: deployed ≠ live — the bot step waits until a non-draining container reports the deployed build.commit on /healthz) + the runner (account/clean-tree/origin-main checks, env -u CLOUDFLARE_*, preflight wait-and-retry with a heartbeat, --force gating, version → live table). operatorRoot.ts is where a deploy's files live — the checkout, or from the published package the operator's directory (profile, config), the package's assets (templates, manifests) and a work area <root>/.switchboard/; workArea.ts materialises that work area from the shipped tree (copy once per CLI version, npm ci --workspace per Worker, a stamp) and host.ts binds both to this process; images.ts is where each Worker's container image comes from under the profile's images mode (build: its Dockerfile; registry: the release's published image copied into the account registry once per version) and registryTransfer.ts + registryTransferHost.ts are that copy and the account registry's listing — a registry-to-registry transfer over HTTPS under a credential minted from CLOUDFLARE_API_TOKEN, no container daemon, no process — with imagesHost.ts holding that credential once per account for this processThe only supported way to deploy prod; see README "Deploying on Cloudflare Containers". Every path goes through the operator root: a checkout resolves exactly as before, the package never writes inside itself, and the git checks apply to a checkout only (release-and-deploy item 24). imagePins.ts parses the Dockerfiles: every toolchain version in an image is pinned EXACTLY and imagePins.test.ts fails if a floating tag (@latest, a range, a bare name, a FROM with no tag) returns — pnpm@latest made the pnpm major a property of the last image build and cost a resident (resident-repos item 53). buildStamp.ts is what every Worker answers as build on its own /healthz: deploy/bin/build-stamp.mjs runs in place of bare wrangler deploy for the resident/memory/sandbox scripts and injects the tree's commit via --define, so no build identity is ever hand-bumped — the resident's old BUILD_MARKER constant went unbumped across five deploys (execution.md item 13)
deploy/secrets.manifest.json, deploy/bin/put-secrets.mjsThe one list of every Worker secret (name → Worker(s), optional?, note) and the provisioner behind each npm run secrets: pipes ~/.secrets/switchboard/<NAME> into wrangler secret put for that Worker's entries; refuses before uploading anything when a required value has no local file; optional entries are skipped by name. Values come from the profile's secretsSource — a directory of <NAME> files (~/.secrets/switchboard/ by default) or a secrets-manager item. src/core/secretsManifest.test.ts checks every entry against its Worker's Env interface and that no secrets.txt survivesShared bearers (MEMORY_TOKEN, SANDBOX_TOKEN, RESIDENT_*) are one value on every listed Worker — a Worker holding a different mint answers its peers with 401. Public URLs (STATE_WORKER_URL) are wrangler vars, never secrets
deploy/cloudflare/Worker+Container shim: the public Worker fronts the bot's containerRecommended deploy target; scheduled() fires the registry's bot schedules (src/core/schedules.ts): the internal healthz keep-alive touches /healthz, run schedules POST /ingress as cron and record the firing on the state Worker (STATE_WORKER_URL var + MEMORY_TOKEN); typecheck via its own strict tsconfig.json (npm run typecheck)
deploy/cloudflare-resident/Resident Worker: always-warm per-repo DOs on Cloudflare Sandbox 1.0, R2 snapshots, refresh alarms + the resident-watchdog cron (registry entry in src/core/schedules.ts; each pass recorded on the state Worker when STATE_WORKER_URL + MEMORY_TOKEN are set); gc.ts = the pure residency-GC decisions (finished-ref reclamation, LRU eviction pick) under plain-Node vitest; the disk budget (df/du sampling, the reserve, thread-cost projection, admission + coldest-idle eviction, the disk-pressure refusal text) is src/execution/residentDiskBudget.ts, imported like residentDisk/residentRefreshHostname from the deployment profile; contract in docs/reference/specs/resident-repos.md; holds its own GitHub App secrets (second credential domain)
deploy/cloudflare-docs/The project's docs site: assets-only, no main, no DO, no container — nothing to roll over, which is why CI may deploy it on every docs change. Its script name (<name>-docs) and hostname come from project.json; only the account comes from the profilenpm run deploy builds docs/ first, so a stale dist cannot ship. Public, workers_dev off. NOT a Worker of an installation and NOT part of deploy all: an installation never deploys a copy of the site
Dockerfile, docker-compose.ymlThe bot's image, and the local loop that runs it on a dev boxProduction is the Cloudflare deploy above; compose is for one trusted operator
packages/switchboard/, src/packageRoot.tsThe npm package the release publishes (packaging.md): build.mts bundles src/cli.ts with esbuild and copies the files the CLI reads (the examples, project.json, what the tree tracks under deploy/ minus tests, the src/ files the Workers import, the root manifest and lockfile) to dist/assets/ with a source.json naming the version and commit; packageRoot.ts is the one resolver that finds those files in a checkout, the image and the package; the smoke test installs the tarball and runs itThe CLI only — the bot is the image; the deploy commands run from any directory with the package (src/deploy/operatorRoot.ts), the bot's own image alone still builds from a checkout. Dependencies are derived from what the bundle imports; the version rides the release PR as release-please extra files