Developing Prepcook#
This is the guide for changing Prepcook: how it is put together, where things live in the repository, how to build and check it, and how to add to it. The conventions agents and people follow while editing (naming, comments, generated files) are in AGENTS.md; how to propose a change is in CONTRIBUTING.md.
Architecture#
Prepcook is two services and the coding agents they start.
browser (packages/local-web)
│ HTML, /api/* │ /v1/* writes, /v1/shape reads
▼ ▼ (owner token from /api/auth/token)
┌──────────────────────────────┐ ┌──────────────────────────────────────┐
│ prepcook (crates/server) │────▶│ board service, Docker (crates/remote) │
│ native, port 4100 │ /v1 │ remote-server :4110 Axum │
│ ├ /api: Settings, cards, │ │ electric ElectricSQL shapes │
│ │ approvals, evidence, │ │ remote-db Postgres 16 │
│ │ scripts, workspaces │ └──────────────────────────────────────┘
│ ├ stage orchestrator, ticket│
│ │ pickup, MR monitor │──── tracker, notification and evidence APIs
│ ├ script runner (app runs) │
│ └ SQLite, config, secrets │
└──────────────┬───────────────┘
│ spawns, per card and stage
▼
┌──────────────────────────────┐ ┌──────────────────────────────────────┐
│ agent session (claude, …) │────▶│ prepcook-mcp (crates/mcp), stdio │
│ cwd: the card's git worktree│ │ card, app-run and evidence tools, │
│ --mcp-config, --add-dir │ │ calling the server's /api │
└──────────────────────────────┘ └──────────────────────────────────────┘The native server (prepcook, crates/server) serves the built UI,
answers /api, and runs the background work: the stage orchestrator
(services/stage_orchestrator.rs), which polls the boards and starts each
stage; the ticket pickup (tracker_watcher.rs); the merge-request monitor
(pr_monitor.rs); the seat monitor and the daily digest. It is the board's
single owner: it holds the board's secret and signs a short-lived owner token
for every request to it (crates/remote/README.md).
The board service (crates/remote, its own Cargo workspace) holds the
board in Postgres and streams it to the UI through ElectricSQL. prepcook up
runs it in Docker from the published image, or from this checkout with
--compose crates/remote/docker-compose.yml. The UI reads the board through
Electric shapes and writes to the board's /v1 API directly, with an owner
token the native server mints at GET /api/auth/token; everything else goes
through the native server.
Where state lives#
| What | Where |
|---|---|
Boards, columns, cards (with their work class, verdicts, approvals, origin and triage report in extension_metadata), comments, tags, pull-request links, the knowledge store and the RCA graph |
The board's Postgres (crates/remote) |
| Repositories, workspaces, sessions, execution processes and their logs | SQLite, db.v2.sqlite in the app data directory (crates/db) |
| Settings | config.json in the app data directory (services/config/versions/v10.rs) |
| Secrets | ~/.config/prepcook/secrets.env, mode 600 (services/secrets.rs, prepcook secret) |
The board's Compose file and generated .env |
board/ in the app data directory |
| The agents' MCP file and skills | agent-mcp.json and agent-skills/ in the app data directory |
| Worktrees | /var/tmp/prepcook/worktrees on Linux, $TMPDIR/prepcook/worktrees on macOS |
The app data directory is ~/.local/share/prepcook on Linux and
~/Library/Application Support/prepcook on macOS for a release
build, and dev_assets/ in the checkout for a debug build
(crates/utils/src/assets.rs).
What runs in the worktree#
Each card's stage runs in a git worktree of the board's repository, on a
branch of its own. Before a Plan or Execute session starts, the orchestrator
stands the app up there from the repository's .prepcook/scripts.toml, on
the board's reproduction target (scripts.md), and the agent
reaches it through the app_status and app_restart tools. The agent writes
its reproduction harness under .repro/<card key>/ (excluded from git through
.git/info/exclude) and its RCA or spec as docs/specs/<card key>.html; when
a planning verdict lands, the server files that document in the knowledge
store. Review runs in a fresh worktree branched from the implementation
branch, so it never sees the planning files.
How sessions get MCP servers and skills#
Nothing is installed into the agent's own configuration. At startup, and when
the evidence sources change, the server writes agent-mcp.json in the app
data directory (services/agent_mcp.rs): the prepcook server (the
prepcook-mcp binary beside prepcook) and the MCP server of each evidence
source that has one. Claude Code gets the file with --mcp-config and the
skills in assets/agent-skills/ with --add-dir, both for that session only;
Codex gets the prepcook server as a thread config override; the other agents
read only their own configuration
(configuration.md).
Repository layout#
Rust workspace (Cargo.toml):
crates/server: theprepcookbinary: the HTTP API (src/routes/), the CLI (src/cli/:up,down,status,secret,integrations describe) and thegenerate_typesbinary.crates/services: everything the server does: the orchestrator, the config and its versions, the playbooks, the integration areas (notifications/,tracker/,reproduction/,evidence/,integrations.rs,agents.rs), the script runner, the card API's logic.crates/executors: the coding agents: one executor per agent, their descriptors, log normalisation, MCP wiring anddefault_profiles.json.crates/mcp: theprepcook-mcpbinary, the MCP server agent sessions and the toolkit use.crates/db: the local SQLite models and migrations (SQLx, offline query data in.sqlx).crates/api-types: types shared by the server and the board service (cards, comments, work classes, triage reports, decisions).crates/git: git operations;crates/git-host: GitHub, GitLab and Azure DevOps (pull requests, comments).crates/worktree-manager,crates/workspace-manager: worktrees, the warm worktree pool, and the workspaces built on them.crates/deployment,crates/local-deployment: theDeploymenttrait the routes are written against, and its local implementation (processes, PTYs, containers).crates/utils: shared helpers (paths, responses, logs, version).crates/client-info,crates/remote-info: the server's own address and the board's, set once at startup.crates/preview-proxy: the separate server that serves app previews in the UI.crates/ws-bridge: WebSocket bridging between the UI and upstream streams.crates/server-info: an empty manifest, not a workspace member.
Separate workspace: crates/remote, the board service (its own
AGENTS.md and README.md).
Frontend (pnpm workspace):
packages/local-web: the app's entry point (Vite, TanStack Router; routes insrc/routes/). Itsdist/is embedded in the server at compile time.packages/web-core: the app's pages, features, Settings sections, dialogs and i18n.packages/ui: the shared component library (@prepcook/ui).packages/public: static assets served with the app.
Everything else:
shared/: generated TypeScript types (types.ts,remote-types.ts) and agent tool schemas. Never edited by hand.toolkit/: the local skills generated from the playbooks, and their generator.assets/: packaged assets (the agent skills, the brand).dev_assets_seed/: what a debug build'sdev_assets/starts from.npx-cli/: theprepcooknpm package, which downloads the release binaries.scripts/: dev and release helpers, the config-docs generator, the manual-testing helpers (webhook-echo.mjs,fake-deploy.sh,fake-destroy.sh) and the screenshot script.docs/: the documentation;docs/images/the screenshots.
Build, run and test#
You need Rust through rustup (rust-toolchain.toml picks the version), Node
20 or later and pnpm 10. Docker only for the board.
pnpm install
pnpm -C packages/local-web build # the server embeds the built UI
cargo build --release --bin prepcook --bin prepcook-mcp # target/release/To work on it:
pnpm run dev # server and web app with hot reload, ports chosen for youThe server reads the board's address from PREPCOOK_SHARED_API_BASE
(prepcook up sets it to http://127.0.0.1:4110). Start a board first, with
prepcook up --compose crates/remote/docker-compose.yml from a release build
or pnpm run remote:dev.
Checks, all of which CI runs:
| Command | What it checks |
|---|---|
cargo fmt --all -- --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace |
The Rust workspace. pnpm run backend:lint also builds the qa-mode feature. |
The same with --manifest-path crates/remote/Cargo.toml |
The board service. |
pnpm run generate-types:check, pnpm run remote:generate-types:check |
shared/types.ts and shared/remote-types.ts match the Rust types. Regenerate with pnpm run generate-types (and remote:generate-types). |
pnpm run ui:check, web-core:check, local-web:check |
TypeScript. |
pnpm run local-web:lint, ui:lint |
ESLint. |
pnpm --filter @prepcook/<package> run format:check |
Prettier, per package. |
node scripts/check-unused-i18n-keys.mjs |
Every i18n key is used. |
pnpm run local-web:legacy-path-guard |
No import from a retired frontend path. |
python3 toolkit/generator/generate.py --check and python3 -m unittest discover -s toolkit/generator -v |
The generated skills match the playbooks (Playbooks and the toolkit). |
cargo deny check licenses |
Dependency licences, both workspaces (deny.toml). |
Two more are run by hand before pushing, because they need the binary:
python3 scripts/gen-config-docs.py --check (the provider tables in
configuration.md) and pnpm -C packages/local-web build.
pnpm run format, pnpm run lint and pnpm run check run most of the above
at once.
Both Rust workspaces build with SQLX_OFFLINE=true from the committed query
data. After changing a query, pnpm run prepare-db (local) or
pnpm run remote:prepare-db (board, needs Postgres) refreshes it.
CI is .github/workflows/test.yml: jobs rust, licenses, frontend,
toolkit and secrets (gitleaks), on every pull request and push to main.
The factory pattern#
Every integration area is built the same way, so a new provider is a file and a handful of enum arms, and Settings and the documentation follow from it.
- One module per area under
crates/services/src/services/<area>/:mod.rsholds the trait, the kind enum, thebuild()function and the types every provider answers in; each provider is one file. - A trait and an enum of providers. The trait (
Notifier,Tracker,ReproTarget,EvidenceSource) is dispatched withenum_dispatchover an enum with one variant per provider (NotifierProvider,TrackerProvider,ReproTargetKind,EvidenceProvider). The kind enum (NotifierKind,TrackerKind,ReproductionTarget,EvidenceKind) is the config string. build()gives callers nothing to call when nothing is configured:Nonefor notifications, ticketing and reproduction (a repository with no manifest), an empty set for evidence. A provider missing what it needs is left out, and logged, rather than built half-configured.- A source that rebuilds on change. Callers do not build providers; they
ask a source for the current one (
TrackerSource::current(),NotificationSource::current(board),EvidenceSources::current(…),ReproductionSource::current(…)). The source resolves the area's config section with its secrets, compares it with what it built last, and rebuilds only when it differs, so a change in Settings or the secrets store applies on the next call without a restart. - A descriptor per provider. Each provider has
fn descriptor() -> Descriptor(services/integrations.rs):kind,display_name, a one-sentencesummary,fields(each akey,label,kindofText,Secret,Url,Select { options },BoolorNumber,requiredandhelp), thesecretsit reads (derived from its secret fields), adocs_url, andavailable(false for a provider that is named but not implemented, like Linear).integrations::descriptors()collects every area's, and the coding agents' as theagentsarea. - Secrets through the store. A secret field's store name is
<KIND>_<FIELD>upper-cased (secret_name; a field already named after its kind is not prefixed twice).resolve_secretreads it from the secrets store, then the config, then the environment (StoreFirst); a board's own entry reads its config value first (ConfigFirst). - A connection test. Each provider's
test()makes one cheap read (or, where a service offers nothing to read, sends a test message) and says what it found.POST /api/integrations/{area}/{kind}/test, with Settings' unsavedfieldsin the body, runs it throughintegrations::test. - Settings is generated.
GET /api/integrations/descriptorsfeeds one generic component,IntegrationSection(packages/web-core/src/shared/dialogs/settings/settings/IntegrationSection.tsx), which each area's section (NotificationsSettingsSection,TicketingSettingsSection,EvidenceSettingsSection,ReproductionSettingsSection,CodingAgentsSettingsSection) renders with its own entries; a new field or provider appears without frontend work. - The documentation is generated.
prepcook integrations describe --jsonprints the descriptors, andscripts/gen-config-docs.pywrites the provider tables between theBEGIN GENERATEDandEND GENERATEDmarkers in configuration.md;--checkfails when they are stale. It runscargo run --bin prepcookunlessPREPCOOK_BINnames a binary. - Config fields are added to
v10.rswith#[serde(default)]. A new config version is only for a field whose meaning changes. - Tested against recordings. Every provider is tested without the
network: HTTP providers against
wiremockwith the service's recorded responses, CLI-driven ones through a recorded runner (GhRunneringithub_issues.rs), file and script ones against temporary directories.
Add a notification provider#
Providers live in crates/services/src/services/notifications/
(notifications.md).
- Create
notifications/<kind>.rswith a struct andnew(&TargetSettings, …) -> Result<Self, NotifyError>, reading its fields withsettings.get("<key>")and answeringNotifyError::NotConfiguredfor a missing required one. - Its
descriptor():Descriptor::new("<kind>", "<Name>", "<one sentence>", vec![Field::new(…)…], "<docs url>"), withFieldKind::Secretfor credentials (the store name follows from kind and key). - Implement
Notifier:kind(),send(&Event)(start fromEvent::headline(),body()andlink()),conversational()(Noneunless the service can read replies back, then implementConversationaltoo), andtest(). - In
notifications/mod.rs:pub mod <kind>;, a variant inNotifierKind(andALL,as_str,descriptor) and inNotifierProvider, and its arm inbuild_provider. - Tests in the file, against
wiremock(seewebhook.rsandtest_supportinmod.rsfor the settings and context helpers). - Regenerate:
pnpm run generate-types(the kind is a TypeScript union),python3 scripts/gen-config-docs.py, and a section in notifications.md.
Targets are config entries { "kind": "<kind>", "events": […], …fields } under
notifications.targets; no config struct changes.
Add a ticketing provider#
Providers live in crates/services/src/services/tracker/.
- Config fields. A tracker's settings are keys of the flat
ticketingsection,TrackerConfiginconfig/versions/v10.rs: add each new one with#[serde(default)], and a variant inTrackerKind(withALL,label,as_str, andmode()returningTicketingMode::Tickets). - Create
tracker/<kind>.rswith a struct built from a resolvedTrackerConfig, adescriptor()naming its fields by their config keys, and animpl Tracker:is_configured,issue_url,is_valid_key,test,search/find/fetch/get_issue,comments/post_comment,transition/enter_stage(map eachTicketStagethrough thestatus_mapping),assignee/assign_to_self,identity,assignable_usersand the rest. An operation the service has no API for answersTrackerError::Unsupported. - In
tracker/mod.rs:pub mod <kind>;, a variant inTrackerProvider, and arms indescriptorandbuild_provider. A secret field also needs its slot insecret_slot, soresolvecan fill it from the store. - Settings: add the new keys to
EMPTY_TICKETING_CONFIGinTicketingSettingsSection.tsx(the type check fails until you do). - Tests against
wiremock(jira.rs'sfixture_tests) or a recorded runner for a CLI (Recordedingithub_issues.rs). - Regenerate the types and the config docs.
Add an incident source#
An incident tool is a ticketing provider whose TrackerKind::mode() is
TicketingMode::Incidents, as PagerDuty is (tracker/pagerduty.rs,
incidents.md). Follow the ticketing steps, then:
mode()returnsIncidentsfor the new kind, andINCIDENT_KINDSinTicketingSettingsSection.tsxlists it, so Settings offers it under Incidents.build()refuses a kind whose mode does not matchticketing.mode.search/findanswer open incidents (the pickup imports each new one as an investigation;origin_forgives the card anincidentorigin from its URL).post_commentadds a note;enter_stageacknowledges onPlanning, and onTriagedresolves only whenincidents.resolve_on_triageis set;Done(the merge, or Clean up for a card with no pull request) resolves.- It never pages: no escalation, no added responders, no urgency changes.
- Tests cover the stage mapping with and without
resolve_on_triage(pagerduty.rshas the pattern).
Add an evidence source#
Providers live in crates/services/src/services/evidence/
(evidence.md).
- Create
evidence/<kind>.rswith a struct built fromSourceSettingsand adescriptor()whose fields are wrapped bywith_common_fields(labelfirst;areasandnoteslast). - Implement
EvidenceSource:kind,label,describe(the paragraph the agent gets in{{product.evidence_tools}}),mcp_server(an MCP server for agent sessions, orNone),search(Ok(None)when there is no API Prepcook drives as configured) andtest. - In
evidence/mod.rs:pub mod <kind>;, a variant inEvidenceKind(ALL,as_str,descriptor,display_name) and inEvidenceProvider, and its arm inbuild_provider. - Tests against
wiremock(sentry.rs) or temporary files (log_files.rs), and a snapshot ofdescribe(). - Regenerate the types and the config docs.
Add a reproduction target#
Targets live in crates/services/src/services/reproduction/
(scripts.md).
- Create
reproduction/<kind>.rswith a struct over the sharedScriptRunner, adescriptor(), and atest(&AppRunsConfig)for its global settings. - Implement
ReproTarget:kind,try_reserve(its parallelism limit),stand_up,seed,test,verify,run_step,tear_down,restart,session_env(thePREPCOOK_APP_URLand seed variables an agent gets) andstatus. Every target hands back the sameAppRun, so the harness, the verify and the agent tools see a URL whatever the target. - In
reproduction/mod.rs: a variant inReproductionTarget(ALL,as_str) and inReproTargetKind, and arms indescriptor,build(refuse a manifest that does not describe the target, asremotedoes without[app.remote]),for_kindandtest_target. - The manifest: a target that needs its own commands gets a table in the
.prepcook/scripts.tomlschema (services/scripts/) and a section in scripts.md. - Tests against a temporary worktree with a manifest whose steps are
shell commands (
remote.rsandmod.rshave the pattern). - Regenerate the types and the config docs.
Add a coding agent#
An agent is an executor in crates/executors.
- The executor,
crates/executors/src/executors/<agent>.rs: a struct implementingStandardCodingAgentExecutor(spawn,spawn_follow_up,normalize_logs,default_mcp_config_path, availability) and itsdescriptor()(AgentDescriptor::new): display name, summary, the command a session starts from, how it signs in, how its sessions get MCP servers (McpWiring), and its economy variant if it has a cheaper model. - The
CodingAgentenum inexecutors/mod.rs: a variant for it. The compiler then points at the matches that must name it:capabilities, andBaseCodingAgent::descriptorincrates/executors/src/descriptor.rs.get_mcp_configneeds an arm only when its MCP servers live under a key other thanmcpServers. crates/executors/default_profiles.json: itsDEFAULTvariant, plus any variant the descriptor names as its economy variant.- Tests: log normalisation against recorded output lines (as
claude.rsandcodex/normalize_logs.rsdo);descriptor.rs's tests check every agent's descriptor against its profiles. - Regenerate:
pnpm run generate-typesandpython3 scripts/gen-config-docs.py. Settings → Agents and the coding-agents table in configuration.md come from the descriptor.
Which agent runs which stage is agents.stages.<stage> (services/agents.rs,
configuration.md).
Playbooks and the toolkit#
The stage prompts are in crates/services/src/services/config/playbooks.rs,
keyed "<work_class>:<stage>" plus pattern:planning, reviewing,
comment_reply and rca_analysis. They name no product or machine:
{{placeholders}} and {{#if}} blocks are filled when a session is launched,
from the card, the board's repository and its .prepcook/scripts.toml
(services/playbook_context.rs; the table is in
playbooks.md). A saved config keeps the texts it
was saved with; a shipped text a user has not changed is replaced only when
the old text is listed in config/versions/superseded_playbooks.rs.
The toolkit's skills (toolkit/skills/) are generated from those texts:
toolkit/generator/generate.py takes each skill's preamble
(generator/preambles/), runs the playbook through the substitution table
(generator/substitutions.py, every difference between an orchestrated agent
and a person at a keyboard, with its reason) and cuts it at the skill's anchor.
After any playbook edit, run python3 toolkit/generator/generate.py and commit
the result; CI runs --check and the table's tests. An improvement someone
makes to a generated skill is promoted back as a playbook change, usually with
a matching substitution rule, so it reaches both the orchestrated agents and
the toolkit on the next generation (toolkit/README.md).
prepcook-self-review is hand-written and edited in place.
Extension points: work classes and the card API#
The investigation class shows how a work class is built, and a new one
follows it. A class is a WorkClass variant (crates/api-types/src/issue_ext.rs,
with its tag word in as_str/parse and its place in the tag order); its
stages are playbooks keyed by it (investigation:planning); and the
orchestrator decides where it stops. An investigation's planning ends with a
TRIAGE REPORT comment, parsed by parse_triage_report into a
TriageReport, recorded on the card, sent as investigation_completed
(notifications::Event) and copied to the tracker; the card is then triaged
(IssueExtMetadata::is_triaged) and stays in Plan, or moves to a Triaged
column a board added, and POST /api/cards/{id}/convert (services/cards.rs) turns
it into a bug, carrying the plan over when the RCA is at high confidence and
the harness is still on disk (method.md).
The card API (POST /api/cards, GET /api/cards/{id},
POST /api/cards/{id}/convert; api.md) is how other services
put work on the board. Its routes are crates/server/src/routes/cards.rs and
its logic services/cards.rs, behind a small board trait so it is tested
without a board. A filer identifies itself with origin (OriginKind:
tammy, incident, api); a new kind of filer that the card should name is
a variant there. It is a stable contract: change it additively, and keep
api.md in step.
Releasing#
- Set the version in
crates/server/Cargo.toml,package.jsonandnpx-cli/package.json; the workflow refuses a tag that does not match all three. - Run manual-testing.md on a release build.
- Push an annotated tag
v<version>; its message becomes the release notes. .github/workflows/release.ymlbuilds the web app once, then the binaries on macOS arm64, macOS x64 and Linux x64, packages each withscripts/package-release.shintoprepcook-<version>-<target>.tar.gz(prepcook,prepcook-mcp,LICENSE,NOTICE), and publishes them withSHA256SUMSas a GitHub release. In parallel it builds the board image fromcrates/remote/Dockerfileand pushesghcr.io/nwisehart/prepcook-boardtagged with the version andlatest.- The first time the image is pushed, GitHub makes the package private: set
its visibility to public in the package's settings, or
prepcook upcannot pull it for anyone not signed in to ghcr.io.prepcook upalways pulls the tag matching its own version, neverlatest.
pnpm run build:npx (local-build.sh) packages this machine's binaries the
same way, into npx-cli/dist/, to try the npm CLI without a release.
Manual testing#
The automated tests cover the pieces; manual-testing.md covers the whole, by hand, from a cold clone: the board, a bug with no tracker, investigations, every integration area, Tammy and the clean-up. Run it before every release, and the sections a change touches before merging it.
The website#
The project site is published at https://prepcook.dev/ from site/
(@prepcook/site). Its pages are generated from README.md,
CONTRIBUTING.md and docs/*.md by site/build.mjs, so a content change goes
in those files, not in site/; only the home page and the Tammy page are
written by hand (site/src/). Build and preview it with:
pnpm --filter @prepcook/site run build
python3 -m http.server -d site/dist # or: npx serve site/distThe build ends with a link check (site/check-links.mjs) that fails on any
link, image or #anchor that does not resolve; every link it writes is
relative, because the site is served under /prepcook/. A new page in docs/
needs an entry in the sidebar list at the top of build.mjs.
.github/workflows/pages.yml publishes the site on every push to main that
touches it, once the repository's Settings → Pages → Source is set to
GitHub Actions.