Code map
Where each part of Switchboard 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, harness-pi.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, delivery | src/core/runRegistry.ts, runStore.ts, runsService.ts, runMetrics.ts, trace/, src/channels/liveView.ts | run-history.md, live-view.md, tracing.md, run-metrics.md |
| Channels: Slack (transport only), HTTP, MCP ingress | slack, http, mcp | src/channels/ | slack-channel.md, http-ingress.md, mcp-ingress.md |
| Agents (data), the model layer, executors | agents, review, coding, ship, research, explore, conductor, general, providers, resident, sandbox | src/agents/, src/core/provider.ts, src/core/harness/piAi.ts, src/channels/modelProxy.ts, src/execution/ | agent-*.md, execution.md, resident-repos.md |
The harness contract — the six clauses any process that runs a model loop for a run is held to, the Harness object the run loop drives every run through, the facts a row carries — and the pi harness, its one implementation: pi in the run's container, the bridge onto the run's events, the extension and the gate, the mirror; and pi's model library as the bot's provider layer for the calls made outside a run (the operator, thread-reply gate, reflection) | harness | src/core/harness/, src/channels/harnessRoutes.ts | harness.md, harness-pi.md |
| Memory, skills, MCP tools, GitHub tools, the toolset table | memory, skills, tools | src/core/memory/, src/skills/, src/mcp/, src/tools/ (toolsets.ts: the tools the bot relays to a run's pi, by preset; pi's own workspace tools follow the identity, never this table) | memory.md, skills.md, mcp-tools.md, github-tools.md, harness-pi.md item 7 |
| 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 load harness: concurrency baselines and the drivers (resident, sandbox, e2e, the pi spike, the one-door replay and its checked-in compound and imperative sets), a refusal counted by its machine token, the receipt renderer | load | scripts/load.ts, src/load/ | load-harness.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 |
Lifecycle ownership
Canonical pull ownership is read from the existing store; a routing read never grants a writer. A unit reserves exact action/target/native calls atomically in unitEffect.ts. Original run publication and terminal native receipts remain in the run ledger. workflowReconciliation.ts, reconcileExecution.ts and the report helpers use the existing outbox/alarm and exact report receipts. maintenanceAdmission.ts and maintenanceSweep.ts reuse the unit store for command/watch intent, with no native Workflow for a logical maintenance owner. Workspace finalization retains the original owner, generation, fence and revision. The decision is record 0090; the living proofs are the linked specs.
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), 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 one fast path, what answers before any model turn (answerChatCommand = stage A, the typed grammar and the inline command runs, 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); operator.ts — the one model door for plain chat (runOperator over typed bind_preset, command, ask and grounded-read tools; one no-call repair then the typed general floor; operatorStage and executeOperatorDecision carry the bind through the normal authorization and execution paths); route.ts — shared one-door support (routing-and-config items 21, 25 and 29: routableCommands, providerStructuredModel, confirmation minting, and historical route metadata/rendering retained for old rows and the load verifier; no readers' prompt, parser or dispatch stage); 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 the hand-off to the pipeline (handOffToCoordinator) — every admitted request, a resume at review included — and the reply; over ShipDeps, the run slice plus the seams only ship uses: the preflight's GitHub facts, the coordinator's record store and the shim's create call and status); reattach.ts — a resumed run's workspace: where its row says it is (carriedWorkspaceBinding), what happens when it cannot be re-attached (abandonLostWorkspace: the note, the card, the row closed; prepareRestartTurn: the request as a new run); 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/budgets.ts | Every wall clock as one table: preset asks, round floors, allowances, the derived reserve, carve and fit (decision 0046; harness-pi.md item 15, agent-ship.md item 8) | Node-free and registry-free: the registry, the pipeline's driver and the Workers import it, never the reverse; budgets.check.test.ts asserts the fits, so a number that breaks another's assumption is a red build |
src/core/shipPipeline.ts, src/core/ship/ | What every ship surface shares and the pieces of the agent:ship pipeline that live in the bot (docs/reference/specs/agent-ship.md): shipPipeline.ts — the ship config block (maxRounds, maxMinutes, any other key refused at load), resolveShipCaps, shipPresetFor (the preset with the deployment's budget), shipRoundHeader (the card's round header the runner's round route draws), and the interrupted note and the caps' shape re-exported from the runner's machine; ship/preflight.ts — every refusal before a request is handed to the runner and the entry it carries (shipPreflight, shipTaskText, shipBranchName, ShipEntry with its resume); ship/codingChild.ts — the coding child's prompt block (withContractInFirstUserTurn, the dispatcher's placement of a unit's contract in a spawned child's first turn; a review's findings are a message the runner dispatches into the unit thread, composed in coordinator/briefs.ts); ship/reviewChild.ts — the review child's turn (buildShipReviewTurn); ship/contract.ts — the child contract of a plan unit, pure over the plan and spec text (contractFromPlan, contractFromTask, renderContract under ## Contract with the fixed sub-headings and the cut ladder, GUARDS; src/core/commands/contract.ts is its by-hand render, contract render, CLI-only); ship/handoff.ts — the handoff a coding child hands back, pure (parseHandoff, isHandoffShape, redactHandoff, renderHandoffComment, renderHandoffLedgerRows); ship/coordinator.ts — the pipeline's state machine, pure over the coordinator's step returns: the plan graph and its cursor (parsePlanGraph, openPlanCursor, readyUnits, startUnit, settleUnit; unitBranch, parsePlanBranch, planInstanceId, parseShipPlanRequest) and one unit's pipeline (openUnitPipeline — a resume opens it at the review round — nextAction, applyReturn, renderUnitReport — every ending over StepReturns, the round boundaries as CoordinatorNotes), shipInterruptedNote | One ship implementation: no round loop and no child round runs in the bot process — the dispatcher's ship branch (src/core/dispatch/ship.ts) runs the preflight and hands every admitted request to the runner (src/core/coordinator/handOff.ts), and the runner's children are ordinary dispatch() runs; shipPipeline.ts re-exports only what the runner's machine already defines |
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) | 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 targets 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 scopes) 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> sign-in, 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/runMetrics.ts, deploy/cloudflare-memory/runMetricsSink.ts | Run metrics (run-metrics.md, decision 0063): the node-free point contract both sides of the wire import — POINT_COLUMNS naming every blob/double position once, pointOf(record, prices) (undefined for a provisional record; no free text), isRunMetricsPoint, pointTurnsFinal (the emission rule's one question) — and the Worker's sink seam (AnalyticsEngineSink over the optional RUN_METRICS binding / NullSink) | The point travels beside the record on /runs/put and /runs/finish, never on it; the object writes it after its commit, only when the row turned final |
src/core/metrics.ts, src/core/metricsService.ts, src/core/commands/metrics.ts | The run-metrics reader (run-metrics.md items 7–9): parseMetricsConfig, the MetricsSource seam (AnalyticsEngineSqlSource over the SQL API with the costs credential, InMemoryMetricsSource, NullMetricsSource), metricsQueries (three _sample_interval-weighted texts, columns from POINT_COLUMNS), pure buildMetricsReport, createMetricsService/NullMetricsService, and metrics trend on every command surface behind the metrics capability | Three sub-second queries per read, no snapshot; the agent filter is the only user string in a query, validated and quoted |
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/plane/table.ts, src/core/planeService.ts, src/core/commands/plane.ts, src/channels/planeView.ts | The orchestration plane's table (record 0064, the first unit): the pure buildPlaneTable and the health flags of a run, a unit and a pull request; createPlaneService reads the runs service, the pipeline's record store and the merge door's GitHub reads under the viewer's predicate; plane show on every surface; Access-gated GET /plane and /plane.json serving the web shell with a PlaneSeed, rendered by web/src/pages/PlanePage.vue | Read-only: nothing here decides, writes or steers; a flag derives from a fact a row already carries, never from a clock of its own; see docs/reference/specs/orchestration-plane.md |
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/ | Memory across runs: 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/artifacts/ | The artifact store (docs/reference/specs/execution.md item 20, record 0033): ArtifactStore seam with R2ArtifactStore (presigned PUT/GET, signed HEAD, the copy through the bot's Worker) and InMemoryArtifactStore; keys.ts (safeBasename, per-file keys), contentType.ts, the artifacts: config section and buildArtifactStore. | |
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; an entry's email binds the token to a person |
src/channels/requester.ts | boundRequester: who a credential's request is FOR — the person its email names (userId, userName) with the credential as authenticatedAs, else the credential alone (authorization.md item 15) | One rule for HTTP, MCP and the CLI; fail-open to the credential; identity only — every gate decides on chatActorOf(config, msg) |
src/core/coordinator/, src/channels/adminCoordinator.ts, deploy/cloudflare/coordinator.ts, deploy/cloudflare/shared.ts | The ship coordinator — the pipeline's driver — its contract, the Workflow that drives it and the bot steps it calls (docs/reference/specs/http-ingress.md item 9; agent-ship.md items 15 and 16; run-history.md items 47–50): contract.ts — node-free, shared by the bot and both Workers — names the coordinator bearer and the coordinator:step action, the step path prefix, the <parentInstanceId>:<step> key, the run-finished-<runId> event the state Worker's finish sends (sendRunFinished), the parent ship record (CoordinatorInstance) and the unit row (CoordinatorUnit, its resume for a review resumed at the pull request the requester named); instanceStore.ts — the records' seam (state Worker tables / in-memory / null; replace for the leftover of a try whose instance was never created); instancesRoute.ts — the pure halves of the shim's POST /admin/coordinator/instances, both directions (readCreateInstanceAnswer for the bot); instancesClient.ts — the bot's create on its own shim with the coordinator bearer, and its read of an earlier try's instance status; handOff.ts — every admitted ship request as a runner instance (the plan read at the base, the records, the create, the reply; a resume at review as the task row's resume), by tries: an earlier try's status decides — live refuses, a leftover is replaced, seeded ended selection resumes with merged units skipped, while generated publication evidence retains its original owner; briefs.ts — the child's turn composed bot-side from a spawn's typed brief (the contract, the review turn, the fix turn); driver.ts — one full unit lifetime, generated/exact-PR direct entry and the retained seeded sequencer; the Workflow's run() body, node-free: runPlan over a structural step runner and a bot client, the machine's steps under its names, one retry policy, every wait confirmed by read-record, the reply stored as the wire carried it; adminCoordinator.ts — the steps behind the coordinator bearer: spawn (a dispatch() as the parent record's requester into the unit's thread, tagged DispatchOptions.coordinator, retry-safe by the key; a prompt or a brief), read-record (the child's typed artifacts), pr-check, plan, unit-start (the unit's thread opened by the parent's channel), branch, round (the card), unit-end (the report in the thread and on the board issue, the last coding child's rendered handoff under it), merge (the runner's squash of a plan branch's pull request under plan:merge, every guard checked, refused by reason otherwise), finish (the parent's record), authorize; deploy/cloudflare/coordinator.ts — the ShipCoordinator Workflow entrypoint over a narrowed env binding the platform's step and the container bot to the driver; deploy/cloudflare/shared.ts — the container instance and internal origin the entry and the Workflow both address | The spawn never takes an actor from its caller and never steers a live thread (coordinator_thread_live); the coordinator holds no credential — every GitHub fact, Slack post and child run is the bot's, and its only fetch is the container binding's; the machine takes time only from the bot's at; the branch decides who merges — a plan branch is the runner's under plan:merge, a task string's ship branch waits for a person |
src/core/modelProxy/runBearers.ts, src/channels/modelProxy.ts, src/channels/adminModelProxy.ts | The per-run model-credential proxy (docs/reference/specs/model-proxy.md): RunBearerStore — the run-scoped bearer (sbr_<runId>.<secret>) minted by the provision stage as a run's executor attaches, bound to the run, pinned to its resolved model and the preset's maxTokens/maxTurns, expiring at the budget plus BEARER_MARGIN_MS, revoked at the run's finish (RunEnding.onFinished) and by the dispatch's outer finally; modelProxy.ts — POST /v1/messages (Anthropic-shaped) and POST /v1/chat/completions (OpenAI-shaped): the header-only door (decideDoor), the model and output cap pinned (pinRequest), the upstream and the real key from the process's secrets (upstreamFor), the turn guard spent before the call and refused as a turn_budget_exhausted note, one model.turn span per call with the runner's attrs read off the answer (SseMeter, meterAnthropicMessage, meterOpenAiCompletion), the stream forwarded chunk for chunk; adminModelProxy.ts — POST /admin/model-proxy/bearer, an operator's probe bearer for a live run under deploy:write | The shim forwards the two paths blind and the Access gate does not cover them: the bearer is the whole door. The key is revealed into the upstream request and nowhere else; no log line carries a body or a credential. Nothing consumes the proxy yet but its probe — a harness in an execution container will, presenting the bearer as its API key |
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/residentsFeed.ts, src/channels/residentsModel.ts | Residents dash: Access-gated GET /residents (index, each resident folding open to the runs on it and their worktrees) + /residents?stream=1 (its SSE feed) + /residents/<owner>/<name> (detail) — the browser twin of repo list; the handler serves the web shell with the listing/record and the viewer's live repo runs as the seed, the pages render in web/src/pages/Residents*.vue and web/src/components/residents/; residentsFeed forwards the registry's index events and re-reads the listing when a run's attach span ends or its stream seals; residentsModel holds the slug/live/tone helpers, the run → worktree join and the feed reducer the handler and the page share | Reads the resident admin /residents route live per request and per signal — never on a timer; never caches, never puts the bearer in a seed; see docs/reference/specs/resident-repos.md item 42 |
src/core/delivery.ts, src/core/deliverySnapshot.ts, src/core/deliverySnapshotStore.ts, src/execution/githubDelivery.ts, src/channels/deliveryView.ts, src/core/commands/delivery.ts | Delivery indicators: pure buildDeliveryReport turns the merged pull requests' facts (reviews, pushes, workflow runs, the linked board issue) and the run history into issue-to-merge time, first-pass CI, review rounds, the findings and the share resolved with no human edit, per week and per unit; GithubDeliverySource reads those facts over the App's read token (DeliverySource seam, InMemoryDeliverySource the second implementation); SnapshottingDeliverySource decorates it with one snapshot per repository — the facts over the page's widest range, read whole at the listing cap once (a first read, fresh) and refreshed incrementally on delivery.snapshot.everyMinutes by the bot's own refresh loop (the rows touched since the last read, merged in by number; rows past the window dropped), kept on a DeliverySnapshotStore (the DeliveryDO on the state Worker, patched per refresh, or memory) — and serves every request whose range fits it; the gated GET /delivery, /delivery/<owner>/<name>, .json twin serve the web shell with a DeliverySeed rendered by web/src/pages/DeliveryPage.vue; delivery report is the same report on every command surface | The report carries snapshotAt and completeFrom: every surface shows the age, and a read that stopped at its cap says which weeks hold the newest pull requests only; ?fresh=1 / --fresh read GitHub now; nothing written to GitHub; the caller's runs join live under the runs:read predicate; the refresh is a process timer, not a schedule-registry entry; config delivery: validated at startup; see docs/reference/specs/delivery.md |
src/channels/settingsView.ts, src/core/installationSettings.ts, web/src/pages/SettingsPage.vue, web/src/components/settings/, web/src/lib/settingsApi.ts | The settings page (GET /settings*, settings-page.md): an adapter over the command registry — each tab's seed is mcp list / config overrides / config show --channel invoked as the viewer, canWrite the same authorize questions the write handlers ask, every button one POST /api/<group>.<verb>; installationSettings projects the running config.yaml by allow-list (never an env var name or URL). The shared scopes only (record 0041). | |
src/channels/web.ts, web/src/pages/HomePage.vue, web/src/components/home/, web/src/lib/homeModel.ts, src/core/dispatch/handBack.ts | The home page (web-chat.md, record 0043): the chat at /threads and /threads/<conversation> — web.ts is channel adapter #5, POST /threads/<id>/send into dispatch() as the gate's actor (the linked person with the sign-in as authenticatedAs) and the page seeds from the runs service; a conversation is the runs of one web: thread (HomeSeed), an assistant turn is the run itself on createRunPageModel over the run's own stream (live) or its replay (folded open), the composer POSTs /threads/<id>/send and reads a 202 as a live turn and a 200 as a hand-back (HAND_BACK_PREFIX, fills the composer), a steer acknowledgement (the run's input event stamps the turn) or an inline turn; homeModel.ts holds the pure rules | The page holds no rule the pipeline does not; no status word of its own; every motion is opacity plus ≤ 8 px under prefers-reduced-motion; served over scripts/web-preview.ts fixtures until the adapter (PR 3) lands |
src/core/costs.ts, src/core/costsSnapshot.ts, src/core/costsSnapshotStore.ts, src/core/costsService.ts, src/core/commands/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); CostsSnapshotter reads both sources and the run history's usage cells ONCE over the page's widest range into one CostsSnapshot — taken on costs.snapshot.everyHours by the bot's own refresh loop or on request by costs snapshot (costs:write), kept on a CostsSnapshotStore (the CostsSnapshotDO on the state Worker, or memory) — and createCostsService builds every report as arithmetic over it, stamped with when it was taken; the cost dimensions (buildCostsByReport in src/core/costsBy.ts: the usage cells keyed by user, thread, channel, agent or model, priced through src/core/modelPricing.ts — costs.prices over the Anthropic list — and costs by <dimension> on every command surface); Access-gated GET /costs, /costs/<group>, /costs/<group>.json, `?view=users | threads |
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 + machine class (MACHINE_CLASSES, what the executor factory provisions) + 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/execution/ | Executor interface; local, E2B, Cloudflare Sandbox, and resident backends; the factory, which branches on the agent's machine class; the repo-cold repository vet (githubRepoProbe.ts) | 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/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, src/config/profile.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 capability profile in profile.ts (RunProfile — machine class, identity, minutes — the Boundary a scope caps it with, intersectBoundaries across the layers where every other setting overrides, effectiveProfile = preset ∩ boundary with the clip-or-refuse rule per axis; pure, type-only imports, shared with the node-free record contract); 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 |
src/core/harness/contract.ts, src/core/harness/roster.ts, src/core/harness/pi/piHarness.ts, src/core/harness/container.ts, src/core/harness/botHostContainer.ts, src/core/harness/testing/fakeContainer.ts, src/core/harness/testing/scenarios.ts, src/core/harness/testing/drivers.ts, src/core/harness/conformance.test.ts, scripts/harness-conformance-matrix.ts | The harness contract (docs/reference/specs/harness.md): Harness — the object the run loop is handed and calls (open, find, end), with its name, its history word, its disposition table, its effort map and its built-in tools; HarnessRun, HarnessDeps, HarnessSession — what a run hands a harness and gets back; HarnessFacts — the row's facts as a union keyed on the harness's name (harnessFactsOf reads a row by the shape it names, keeps the keys it does not know, and reads a row without the key as pi's), Finding and HarnessMismatchError; piHarness.ts — PiHarness, pi as that object over runPiHarnessOpen, the deployment's compaction thresholds behind it; roster.ts — HARNESS_NAMES, DEFAULT_HARNESS, HarnessRoster, isHarnessName, harnessForPreset: the roster's words (the configuration word harness.<preset>, validated in src/config/validate.ts) and the pick for a fresh run, the roster itself constructed in src/index.ts and src/cli.ts. The container seam beside it (harness.md item 9): container.ts — HarnessContainer, what any harness's process needs of the run's container (make the root the harness proposes, write a file, start the program the start names detached behind a FIFO with its arguments, its stdout filter, its layout and a picked or given port, feed a line, read the log by offset, alive, identity, request into a loopback server, kill the group, tail, remove), ExecHarnessContainer over the run's Executor and its scripts (curl -sS -i for a request); botHostContainer.ts — BotHostHarnessContainer, the same seam on the bot host for a preset without a workspace (the process a child of the bot, fetch for a request), and harnessContainerFor, the choice by machine class; testing/fakeContainer.ts — the in-memory fake every harness test drives. The conformance table (harness.md item 11): testing/scenarios.ts — record 0038's six clauses and the parity rows as functions of a HarnessDriver (start a run from a scripted model, read the run events and the ledger steps, end), with the lint that refuses a row reading neither; testing/drivers.ts — every harness's driver (pi's is pi/testing/driver.ts over the fake container); conformance.test.ts — describe.each over the drivers, so a second harness is a driver added, never a table of its own; npx tsx scripts/harness-conformance-matrix.ts prints harness × row for a PR body | The loop compares no harness word: a fresh run's harness is the roster object the preset's configuration word names (pi without one), a resumed row's the one its facts name — the row's word wins, so a preset flipped between generations never mismatches a run in flight; a foreign row is refused at the seam's door for any caller that is not the loop; run_meta.harness names the harness for the record; the model proxy's two dialects are the roster, and a third is a change to record 0038 |
src/core/harness/pi/, src/core/harness/piAi.ts, src/core/harness/oneHarness.test.ts | The pi harness (docs/reference/specs/harness-pi.md), the first implementation of the contract and the harness every preset runs on until its configuration word says otherwise: process.ts — pi's paths, arguments, environment and files for one run (the bearer under SWITCHBOARD_RUN_BEARER, the proxy as the one provider in models.json, the composed prompt as SYSTEM.md, the extension); transport.ts — PiRpcTransport, commands in order, the log polled into whole records; bridge.ts — every pi event's disposition and the tool_call/tool_result/assistant/run_note it becomes, tool.<name> spans; mirror.ts — pi's messages as the ledger's step records (StepReport, src/core/runLedger/stepReport.ts), and a session file rebuilt from the transcript; extensionSource.ts — the extension pi loads, as text; relay.ts — the registry of live harnesses, the gate (judgeToolCall) and the relayed tools run in the bot; harness.ts — runPiHarnessOpen, the loop's budgets, wind-downs and stops in the words src/core/harness/windDown.ts names (at the harness root: the loop speaks them on every harness); toolRules.ts — the coding preset's tool rules; piAi.ts — PiAiProviders, the config's provider table on @earendil-works/pi-ai for the two calls made outside a run, the router's and reflection's (item 13); oneHarness.test.ts — the source scan that keeps the native loop, both provider adapters and the native tool table deleted (item 1) | The bearer travels in the exec's env and never in a command or a file; the bridge emits no model.turn (the proxy meters — src/core/modelProxy/usage.ts reads each wire shape's usage); every kind pi emits has a disposition or is a harness_error naming it; pi's files live outside the checkout; the harness: block in config puts a preset on pi or opencode by name — pi is the default, native and every other word are refused naming the two |
src/core/harness/opencode/ | The OpenCode harness (docs/reference/specs/harness.md items 12 and 13), the second implementation of the contract, selected per preset by the configuration word (item 8): process.ts — the run's layout under /tmp/switchboard-oc-<runId>/ (per-run XDG roots, a home, the configuration, the plugin directory, the server's and the tailer's logs and pids, the feed), the environment (the bearer under SWITCHBOARD_RUN_BEARER, the roots, every phone-home off, the store in memory, the password as the bearer's hash), the configuration writer (the proxy as the one provider on the run's dialect, the switchboard agent with the ask-on-all rule first and the identity's denies last, the title agent removed, the plugin directory, the compaction thresholds), the launch through the container seam on a picked port, readiness by name (health with Basic auth at the pin's version, the configuration echoed with every clause-carrying key, plugin activation settled), the tailer started beside the server, and the row's facts; tailerSource.ts — the tailer as text, a Node process whose stdout is the feed: every event as one line, the pending asks and the changed messages from the store at every step's end and on every reconnect; client.ts — the routes and shapes hand-derived from @opencode/protocol@2.0.3 and @opencode/schema@2.0.3 with their source lines, the feed's record shapes, the parsers; dispositions.ts — OpenCode's disposition table (the record clause); bridge.ts — OpenCodeBridge and driveOpenCode, the gate over permission.asked and the record over the feed, items 2 and 4; pluginSource.ts — the relay plugin as text (the run's tools registered as direct tools, each call POST /harness/authorize then POST /harness/tool with the model's call id, the relay clause); session.ts — the seed and the rebuild as an authored-session import (openCodeImportBody; the conversation and survival clauses); harness.ts — OpenCodeHarness, OpenCode as the contract's object (open — on a resume the re-attach onto the row's still-answering server, or its end before a fresh start — find, end); testing/driver.ts — the conformance driver over a fake serve driving the real object (the row's recorded server too, for the re-attach), and scriptOpenCodeServe, the same serve bound to a bare container for a run the run loop opens (the configuration word's end-to-end tests), testing/realDriver.test.ts — the same clauses against the real @opencode/cli binary | harness: { <preset>: opencode } puts a preset on it and opencode.compaction is its one deployment setting; until a deployment writes that word, no run reaches OpenCode, though the object drives it end to end in the conformance suite. The bearer reaches the server through the environment only and the configuration file names its variable, never its value; a relayed tool is gated by the plugin's /harness/authorize and runs in the bot, OpenCode's own tools by the permission.asked gate in the container; the client imports nothing from the protocol packages at runtime, and @opencode/cli@2.0.3 is a devDependency the conformance suite runs the real binary from |
src/channels/harnessRoutes.ts | GET /harness/tools, POST /harness/authorize, POST /harness/tool, POST /harness/compaction — what a run's pi extension asks the bot over the run bearer (docs/reference/specs/harness-pi.md item 7) | The proxy's door: the bearer names its run and verifies before the body is read; a run not driving a pi answers 404 |
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, the refresh cycle as a Workflow run the resident-watchdog cron creates per resident and ten-minute bucket (registry entry in src/core/schedules.ts; each pass recorded on the state Worker when STATE_WORKER_URL + MEMORY_TOKEN are set); worker.ts = the entry (the routes, both Durable Objects, the watchdog cron); refresh.ts = the refresh cycle as a Workflow (the ResidentRefresh entrypoint the binding names, re-exported by the entry, plus the cron's creation of each pass); shared.ts = the tunables, stub factories and tracer both import; 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, the attach gate + 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 |