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.
| Area | Scope | Path | Spec |
|---|---|---|---|
| Orchestration: directives, resolution, gates, history, the run — one entry point over a pipeline of stages | dispatcher, core | src/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 validation | config | src/config.ts, src/config/ | routing-and-config.md |
| Commands once, every surface (chat, CLI, HTTP, MCP) | commands, cli, init, setup | src/core/commandRegistry.ts, commands/, commandSurface.ts | command-registry.md |
| Authorization: actors, grants, the policy table, predicates | authz | src/core/authz/ | authorization.md |
| Runs: live registry, history, tracing, the run page | runs, tracing, costs | src/core/runRegistry.ts, runStore.ts, runsService.ts, trace/, src/channels/liveView.ts | run-history.md, live-view.md, tracing.md |
| Channels: Slack (transport only), HTTP, MCP ingress | slack, http, mcp | src/channels/ | slack-channel.md, http-ingress.md, mcp-ingress.md |
| Agents (data), providers, executors | agents, review, coding, ship, research, general, providers, resident, sandbox | src/agents/, src/providers/, src/execution/ | agent-*.md, execution.md, resident-repos.md |
| Memory, skills, MCP tools, GitHub tools | memory, skills, tools | src/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 seed | web | web/ | live-view.md |
| The runtime Workers and the project's docs site | workers | deploy/cloudflare*/ | release-and-deploy.md, docs-site.md |
| Deploy selection, order, and live gate | deploy | src/deploy/ | release-and-deploy.md |
| Human docs and their generated tables | docs | docs/, 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.md | release-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.
| Path | What | Notes |
|---|---|---|
src/core/types.ts | Channel 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 type | The answer is canonicalized once at the dispatcher boundary; raw+canonical on the answer event. See docs/reference/specs/llm-output.md |
src/core/dispatcher.ts | All 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 run | The 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 finally | A 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.ts | Pure 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.ts | Pre-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.ts | The 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.ts — RunControl; activity.ts — activityOf/activityOfEvents, the one-line activity rule the live summary and the persisted record share; state.ts — RunState (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.ts — IndexFeed, 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 fixtures | Backs 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 | and — member-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 id | Pure, 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.ts | Node-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, clone | Imported 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.ts | RunStore seam (put/get/getSummary/list/events/delete; events → null 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 selection | Every 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.ts | The dispatcher's write path: fire-and-forget, drain-counted write(record) with bounded jittered retries on transient errors, none on permanent/route-missing | Runs AFTER the reply; pending() is what the shutdown drain waits on |
src/core/runsService.ts | RunsService: 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.ts | Command 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|build → agent { 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.ts | Dashboard auth as a Strategy (plan D5): one DashboardVerifier — verify(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 shares | index.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.ts | HTTP 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 form | Never emits CORS; every response no-store; carries no reachability rule of its own — the dashboard auth strategy decided before it ran |
src/core/runFriction.ts | Run-friction analyzer: pure analyzeRunFriction(events) → structured diagnosis of delay causes + text report | Analysis 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.ts | Self-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 clusterFriction → proposeImprovements → dedupeProposals 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 surface | Proposals 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 default | NullMemoryStore 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/put | get |
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.ts | Progressive-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 list | add |
src/core/schedules.ts, src/core/scheduleStore.ts, src/channels/scheduledPanel.ts | Scheduled jobs as runs: the schedule registry (SCHEDULES — the one catalog of every cron any Worker runs; each entry names its worker (bot | resident), its action (run |
src/core/ingressTokens.ts | The 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.ts | Live-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.ts | The 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 implementation | npm run typecheck / npm test (the rendering proof layer — the row/page behavior tests live here) / npm run build → web/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.ts | The 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 drift | Never 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.ts | Residents 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 share | Reads 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.ts | Spend 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.vue | Live 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.ts | Slack 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 exists | Transport 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.ts | Pure 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 reports | Slack 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.ts | The 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 abstraction | Caller 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.ts | Agents as data: prompt + toolset + budgets | Add agents here; give them a default model in config |
src/setup/plan.ts, src/setup/host.ts, src/core/commands/setup.ts, docker-entrypoint.sh | switchboard 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 arguments | Derive 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.ts | The 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.ts | The 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 live | Composed after the config block; keep it a few lines — it rides on every turn |
src/providers/ | Provider interface, Anthropic + OpenAI-compatible adapters, registry | OpenAI-compatible endpoints are config-only additions |
src/execution/ | Executor interface; local, E2B, Cloudflare Sandbox, and resident backends; factory | E2B keys sandboxes via data/sandboxes.json; Cloudflare keys them on X-Thread-Key through the proxy Worker in deploy/cloudflare-sandbox/ |
src/execution/resident.ts | ResidentExecutor: attach-on-open client for the resident Worker | Warm-gated selection + named fallback live in factory.ts |
src/runner.ts | Provider-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.ts | coalesceStatus(handle, minMs) — at most one status-card edit per interval, newest frame wins, done immediate | Wraps every run's card in the dispatcher (STATUS_UPDATE_MIN_MS); CoreDeps.statusUpdateMinMs = 0 in tests that assert an intermediate frame |
src/core/mapLimit.ts | mapLimit(items, n, fn) — Promise.all with bounded concurrency, input-ordered results | The one fan-out helper; used by the Slack reconnect catch-up (channels ×4, threads ×4) |
src/config.ts, src/config/validate.ts | Layered 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.ts | agent:x model:p/m inline parsing | |
config/config.example.yaml | All config knobs, documented | Copy 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.ts | npx 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 process | The 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.mjs | The 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 survives | Shared 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 container | Recommended 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/residentRefresh | Hostname 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 profile | npm 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.yml | The bot's image, and the local loop that runs it on a dev box | Production is the Cloudflare deploy above; compose is for one trusted operator |
packages/switchboard/, src/packageRoot.ts | The 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 it | The 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 |