Docs

Agent guide

You are an AI coding agent inside a repo whose backend was assembled with Ctrl AI. Some directories are sealed — content-hashed and tracked. This is the contract for working productively without tripping the locks.

Read docs/how-ctrl-ai-works.md for the full mental model. This doc is the canonical, enforced version of its "For your AI agent" section. The repo's scaffolded AGENTS.md is a terse subset of this contract and points back here for the detail; if this doc and the code ever disagree, the code is right and this doc is a bug.


The one rule

parts/** is not yours to edit, and you import a part only through its seamparts/<name>/src/index (usually aliased @parts/<name>). Everything behind that seam is sealed: verified, content-hashed, and done. You don't have to re-derive auth, billing, email, jobs, or webhooks. They exist; you wire them.

Your job is the product: routes, UI, business logic, your own database tables, and the glue that calls parts through their seams. To change a part, you use a sanctioned verb — never your editor.


Discover before you build — don't re-derive what exists

Before you implement auth, billing, email, jobs, storage, webhooks, or search, check whether a verified part already covers it. Three machine-readable surfaces per installed part tell you everything without reading src/:

File Answers
parts/<name>/contract.json the typed interface, the env it needs, the routes it declares, the vendor adapters it supports, the tables it owns — the machine-readable manifest
parts/<name>/seams.md the only code you must write to wire it (imperative, with signatures)
parts/<name>/SPEC.md what the part does and its threat model

The scaffolded AGENTS.md lists the installed parts; ctrl.spec.json records the capabilities and vendors you were configured with.

The MCP server (@ctrlai/mcp). If the Ctrl AI registry MCP server is connected, prefer it over guessing — it is the agent-native interface:

  • resolve_plan(capabilities[]) — turn "I need billing + email" into the exact parts / adapters / versions / seams to install (the plan carries the "don't edit interiors" rule with it).
  • search_parts(query), get_contract(part), get_seams(part), get_upgrade_plan(part, from, to) — read the catalog and a part's contract/seams.
  • inspect_repo(), doctor_repo(), conform_repo(), provision_repo() — read your own repo's state: what's installed, whether it's wired and reachable, whether it's built to spec, and the vendor setup it still needs.

If no part covers what you need, build it as product code — but don't re-implement a capability a part already covers, even via raw fetch (see the hand-roll blind spot below).


The boundary

There are two halves to the boundary, and both are mechanically enforced.

Half Rule What it protects
Interior Don't edit any file under parts/**. The attested bytes. An edit changes the part's content hash.
Import surface App code may import only parts/<name>/src/index. Your code from coupling to internals that upgrades rewrite.

What "import only the seam" means precisely

The guard scans every app-side source file (everything outside parts/, node_modules/, .next/, dist/, build/, out/, coverage/, and .git/) for import/require specifiers that point into parts/. A specifier is policed only if it is app-relative — it starts with ./, ../, @/, ~/, or parts/. Bare npm packages that merely contain parts/ in their path are not ours to police.

You wrote Verdict
import { sendEmail } from "@parts/email.transactional" ✅ the seam (resolves to parts/<name>/src/index)
import { x } from "parts/billing.subscription/src/index" ✅ the seam, explicit path
import { x } from "parts/auth.session/src/internal/db" ❌ interior — only parts/<name>/src/index is the legal surface
import { x } from "../parts/auth.session/adapters/stripe" ❌ interior import
import "@parts/storage.upload/src/index" then mutate the file ❌ interior edit — hash drift

The @parts/* alias is wired into your tsconfig.json as "@parts/*": ["./parts/*/src"], so @parts/<name> resolves to the part's src (its index). ctrlai wire adds it; never point it deeper than src.

Why the seam and nothing else? The attestation only ever promised the public surface (src/index). Reaching past it — into src/internal/ or adapters/ — couples you to bytes an upgrade is free to rewrite. The next ctrlai upgrade would silently break your code, and the part did nothing wrong. The seam is the one surface that's stable across versions.


How it's enforced, and what failure looks like

Three commands check the boundary. They run at different moments and answer different questions.

Command Runs Question Failure mode
ctrlai guard local pre-commit hook (--staged) Do parts/** bytes still match parts.lock, and does app code import only seams? Blocks the commit
ctrlai audit CI (push & PR) Did this repo respect its contracts? (boundary + imports + attestations, plus heuristic route/env/sprawl warnings) Reddens the build
ctrlai conform CI (push & PR) Did the agent build what was configured? Reddens the build

Note: guard is the local hook; CI runs audit + conform, not guard. The pre-commit hook calls ctrlai guard --staged, which skips fast unless something under parts/ (or parts.lock itself) is staged. (Both on-ramps — create-ctrl-app / ctrlai new and ctrlai init — ship a CI workflow that runs audit and conform; in an init repo conform is a no-op nudge until a ctrl.spec.json exists, then it holds the build to it.)

Hash drift → red

If you edit a file inside parts/<name>/, its content hash no longer matches the pin in parts.lock. guard and audit both report:

parts/<name> was modified (content hash no longer matches parts.lock).

This cannot land quietly and is not downgradeable to a warning — it is a hard FAIL in both the hook and CI. The fix is to undo the edit:

git checkout HEAD -- parts/

…and make your change on your side of the seam instead. If a type error points inside parts/**, the fix is still on your side — restore the part and re-read its seams.md.

Illegal deep import → blocked

The legal specifier is the seam — parts/<name>/src/index, or its @parts/<name> alias. The guard's import scan flags a deep import written as a relative or bare-parts/ path, e.g. parts/auth.session/src/internal/db or ../parts/auth.session/src/internal/db:

src/app/foo.ts: imports part interior "src/internal/db" — only parts/<name>/src/index is the legal surface (docs/02 §7) (saw "parts/auth.session/src/internal/db")

A deep import via the @parts/* alias can't even resolve. The alias maps @parts/*parts/*/src, so @parts/auth.session/internal/db resolves to parts/auth.session/internal/db/src — which doesn't exist, so TypeScript fails it as "cannot find module" before the guard ever weighs in. (That's why the guard's scan only needs to police the relative and bare-parts/ forms.) Either way the import can't land — re-route it through the part's index.

The guard message you'll see

When the hook fires, the CLI prints:

✋ Part interiors are read-only — edits void the attestation and will fail CI.

   Restore:   git checkout HEAD -- parts/
   Then change YOUR side of the seam instead.
   What each part expects from you: parts/<name>/seams.md

   Legitimate part changes go through: ctrlai add | ctrlai upgrade | ctrlai eject

The sanctioned verbs — how to actually change a part

When a part needs to change, use one of these. Each one moves the lock and the spec together, so the build stays green. None of them require — or tolerate — hand-editing parts/**.

Verb Use it to What it does
ctrlai add <part|pack> Install a new capability (or a whole pack). Vendors the verified part into parts/, pins it in parts.lock, records it in ctrl.spec.json, scaffolds env + seams.
ctrlai upgrade <part> Move to a new version, or flip the vendor. Re-vendors the part, re-pins the hash, rewrites the env prefill, and hands you only the declared seam changes.
ctrlai eject <part> Take ownership when a part genuinely can't fit. Moves parts/<name>ejected/<name>, voids its attestation, drops it from the lock.
ctrlai remove <part> Drop a capability you no longer want. Deletes parts/<name>, drops the lock pin and the spec entry, so conform stops expecting it.

Adding

ctrlai add email.transactional               # one capability
ctrlai add email.transactional:postmark      # …on a specific adapter
ctrlai add saas                              # a whole pack

add resolves install order, pulls requires dependencies, skips anything already in parts.lock, and writes ctrl.spec.json (your captured intent). It then prints the seams to write — the only code you touch — and scaffolds any required env into .env.example.

The vendor flip

Swapping a vendor at the same version is the canonical one-commit change:

ctrlai upgrade billing.subscription --adapter=paddle

The lockfile entry's adapter, the vendored adapter tree, and one .env.example line move; the contract didn't move, so there are zero seam changes — your app code stays put. (Community-tier adapters require --allow-community; experimental adapters are never installable.)

Eject — the honest exit

If a part truly can't meet a need, don't hack its interior and don't reach around it. Eject it:

ctrlai eject <part>            # → ejected/<part>
ctrlai eject <part> --to lib/  # custom destination (must stay in-repo, not under parts/)

Eject is deliberately loud and reviewable. It moves the code to ejected/, deletes the attestation, removes the lock pin, and tells you, in warnings, that:

  • you own that code now — the attestation is void, conform no longer covers it, and upgrades stop;
  • you must update imports from parts/<name>/src/index.js to ejected/<name>/src/index.js;
  • if the part owned tables, its _part_migrations rows are left untouched.

Eject is the legitimate way out — the one that shows up in the diff. It is not a bypass. After eject, the capability is still in your repo and your spec; you've just chosen to maintain it yourself.


Your product's own tables

Your product's own schema (the rows your app owns — boards, posts, orders, whatever you're building) goes in migrations/NNN-description.sql at the repo root — not under parts/** (that's the locked boundary). They run automatically after every part's migration (on npm run dev and ctrlai migrate), so they can reference part tables like auth_user. Migrations are forward-only: to change the schema, add a new numbered file; never edit or delete an applied one.


On-spec ≠ working — keep it built to spec

ctrlai conform is the spec-loop check: it joins ctrl.spec.json (what was configured) against parts.lock (what's installed) and your app source. It verifies, per intended capability, that it is:

  • installed — present in parts.lock;
  • attested — the entry carries an attestation (a dev:unsigned dev-tier attestation still counts as locked);
  • on the chosen vendor — the bound adapter matches the vendor you picked;
  • not hand-rolled — no app file imports a vendor SDK for a capability the spec pins.

When you change the backend, hold conform green. The most common way to break it after a vendor flip: the new vendor needs credentials. On-spec is not the same as working.

ctrlai provision <part>     # prints the guided vendor setup with concrete values
ctrlai provision db         # a real Postgres on YOUR account, env-wired, migrated

A conform failure tells you exactly which lever to pull — e.g. a capability on the wrong vendor points you at ctrlai upgrade <cap> --adapter=<the one you configured>; a skipped one at ctrlai add <cap>.

Hand-roll detection is import-based — know its blind spot. conform flags hand-rolling by spotting an app-side import of a vendor SDK (stripe, resend, better-auth, …) for a capability the spec pins. It catches the common case — an agent re-implementing billing with the Stripe SDK instead of wiring the part. It does not catch a reimplementation written against the vendor's raw HTTP API with no SDK import. So: don't hand-roll a capability a part already covers, even via raw fetch. The lock protects what's in parts/; it can't see infrastructure you build entirely outside it.


At the wall — what to do when the hook blocks you

You will hit a moment where a part doesn't do quite what you need, the hook blocks your commit, and the fast move looks like editing parts/** or running git commit --no-verify. Don't.

Situation Do this Not this
Need to change part behavior ctrlai upgrade (new version / vendor) edit parts/**
Part can't meet the need at all ctrlai eject <part> and own it reach around the seam / --no-verify
Don't need the capability ctrlai remove <part> delete parts/<name> by hand
Type error points into parts/** git checkout HEAD -- parts/, fix your seam "fix" the part interior
Vendor flip but it's not working ctrlai provision <part> for creds re-implement against the SDK

git commit --no-verify skips the local hook — but audit and conform still run in CI and will redden the build. Bypassing the hook doesn't make the change legitimate; it just moves the failure downstream and makes it look like you tried to hide it. The sanctioned verbs are faster than a CI round-trip anyway, because they move the lock and the spec for you.


What "verified" means — and what it doesn't (yet)

Two promises, kept separate. Don't conflate them.

Legibility — real today. If anything edits a part's interior, the hash changes and guard (local) and audit (CI) go red. A helpful-but-drifting agent is caught mechanically; the change cannot land silently. This is the common failure mode the lock stops cold.

Cryptographic tamper-proofing — roadmap, not today. "Verified" today means the bytes match the attested content hash and the repo is built to spec. It does not mean a third party cryptographically signed the part: attestations are dev:unsigned, and every trust artifact (parts.lock, ATTESTATION.json, ctrl.spec.json) lives in the repo. A determined writer with repo access who also rewrites the lock and the attestation can re-bless an edited part. Closing that gap needs a root of trust outside the repo — real signing plus server-side hash enforcement plus branch protection — which is the cross-repo / team direction, not the in-repo layer you're working in.

As an agent, the practical reading is simple: the lock makes your infra changes legible, not impossible. Work with it — use the verbs, keep conform green — and every change you make stays a clean, reviewable diff.


Quick reference

# read what each installed part expects of you
parts/<name>/seams.md            # the seams to write — the only code you touch
parts/<name>/SPEC.md             # what the part does

# change a part (never by hand)
ctrlai add <part|pack>           # install a capability
ctrlai upgrade <part> --adapter=<vendor>   # version bump or vendor flip
ctrlai eject <part>              # take ownership, loudly
ctrlai remove <part>             # drop a capability entirely

# make it real / keep it honest
ctrlai provision <part>          # vendor credentials (working ≠ on-spec)
ctrlai provision db              # a Postgres on your account
ctrlai conform                   # did you build what was configured?
ctrlai audit                     # did the repo respect its contracts?

# when blocked
git checkout HEAD -- parts/      # undo an interior edit; fix your seam

The boundary isn't there to slow you down — it's there so the backend stays legible while you move fast on top of it. Wire the seams, build the product, and reach for a verb whenever a part itself needs to change.