Reference
Brick rules
The twelve rules (R1–R12) a brick follows so it can be copied, unchanged, into a project it was not written for.
brico add copies files and does nothing else. It doesn’t rewrite imports, install packages, register routes or touch your schema. So anything that would need adapting has to be designed out of the brick, not patched in afterwards. These rules do that. Each one closes a real failure seen when a captured module was copied into another app and nothing in it compiled.
The rules are enforced by brick-lint in CI, and every brick made by the Builder follows them.
The governing rule
Brick layout
domain/ pure types and logic, safe on server and client
ui/ client entry point; never reaches server/ or adapters/
server/ service factory with injected dependencies
host/ vendored copy of the host contract (do not edit)
testing/ in-memory Repository adapter
__tests__/ tests that must pass inside any targetThe rules
R1 · Relative imports only
No @/…, no ~/…, no leading /, and no self-alias such as @mentorship/core, which the scanner would record as a phantom npm package. Every import is a bare npm package or a relative path.
R2 · Relative imports stay inside the brick
../../lib/utils points outside the brick, so it isn’t copied. This is also why bricks don’t import each other. Cross-brick needs are expressed as ports: one brick declares an interface, another implements it, and they meet only in the host’s composition file. Inside a brick, cross directory imports go through a barrel such as ../domain/index.ts.
R3 · Read environment variables literally
Write process.env.NAME or process.env['NAME']. Destructuring is invisible to the scanner, so the variable never reaches the manifest or .env.example. Most configuration should arrive from the host anyway. Bricks should only read their own feature flags.
const enabled = process.env.WAITLIST_ENABLED === 'true'; // ✓ seen by the scanner
const { WAITLIST_ENABLED } = process.env; // ✗ invisibleR4 · No dynamic import() with a computed argument
The scanner can’t see it, so the dependency never reaches the manifest.
R5 · Non-TypeScript files are checked too
An alias inside a .css, .mjs, .json or template file is copied unchanged and can’t be caught by the TypeScript scan. brick-lint checks those files separately.
R6 · Separate entry points
Ship ui/index.ts, server/index.ts and domain/index.ts, never a root barrel that re-exports both UI and server. Nothing reachable from ui/ may reach server/, and server code imports server-only, so a stray client import fails the build instead of leaking credentials.
R7 · Dependency hygiene
Every npm package used is listed in dependencies or peerDependencies, none is "*", and no adapter SDK (firebase-admin, @prisma/client, …) is a direct dependency. Capture can’t fill in peerDependencies for you, so write them by hand.
R8 · Vendored contract files are byte-identical
host/contract.ts and domain/shared-ports.ts come from one source and are copied into every brick. CI checks that their SHA-256 hashes match. Types disappear at build time, so the copies cost nothing at runtime, and identical declarations are mutually assignable, so the host needs no casts.
R9 · No routes in the brick
Route files must live under src/app/**, outside the brick. Instead, a brick exposes runtime.page(params, searchParams) and the host mounts it at one catch-all route. Route groups, locale prefixes and auth layouts stay host concerns. A brick may ship integration/install.mjs, which reads the target’s tsconfig.json and writes the route shims for it.
R10 · No design-system imports
A brick never imports @/components/ui/*. It vendors its own small set of primitives, styled with Tailwind over the standard shadcn CSS variables, and every var() has a fallback:
--brick-primary: var(--primary, 222 47% 11%);A host with shadcn tokens gets its own brand automatically, and a host without them gets a clean neutral theme. Bricks declare no fonts, so typography comes from the host.
R11 · Portable persistence
All storage goes through a repository port. Ids are strings, timestamps are ISO strings or epoch milliseconds (never Date), and money is { amountMinor, currency }. Queries are limited to what both a document store and SQL can do: equality, in (up to 10), one array-contains, one range field, one order field plus an id tiebreak, a keyset cursor, and a required limit. Uniqueness uses a deterministic id plus createIfAbsent. Every flow that writes more than once must be correct without transactions.
R12 · Ship tests and an in-memory adapter
The in-memory repository has no external imports. It lets the brick run in a demo with no database, and the brick’s own tests run against it inside the target after installation.
Verification gates
A brick is publishable only after it passes all of these, cheapest first:
| # | Gate |
|---|---|
| 1 | brico capture reports neither NOT self-contained nor refusing to capture |
| 2 | brick-lint is clean (R1–R6) |
| 3 | Entry points are isolated (R6) |
| 4 | next build passes in a bare fixture app: no path aliases, no shadcn, no auth, no database. This is the definition of portable. |
| 5 | The conformance suite passes against memory, the Firestore emulator and SQLite |
| 6 | It renders correctly both with fallback tokens and with a real host brand |
| 7 | Dependency hygiene (R7) |
| 8 | Vendored files hash-equal (R8) |
Gate 6 matters more than it looks. A Tailwind-styled brick dropped into an app without Tailwind builds and renders fine, but looks like unstyled HTML. Only a computed-style check catches that.
npm run brick-lint # R1–R6
node scripts/sync-contract.mjs --check # R8