Skip to content

Agent rules

If you are an agent building on Substrat, this is the page to read first. It is the always-on contract — the rules that hold no matter what you touch — and most of what a generated vertical gets wrong is on this page.

This is not a summary written for the website. It is the exact file create-substrat writes into every scaffolded project as AGENTS.md, published here so an agent that has not scaffolded anything can still read it. The two are kept identical mechanically; a rule cannot say one thing in your project and another thing here.

What this page is not is the build flow. Interviewing for a domain, mapping it onto the engines, and landing a design document a human approves before any code is a playbook, invoked when you start a vertical — /substrat in Claude Code, the new-vertical command in Cursor and opencode. Scaffold first with Getting started; this page is what a session already mid-build must never violate.

The mental model

Three layers. You only own the third.

  1. Kernel — free, always. Tenancy (one scope = one isolated database; there is no cross-tenant API), permissions (roles, grants, and a proof path for every decision), events + audit (every mutation emits a kernel-stamped event you cannot mislabel), migrations (journaled per module, applied lazily per scope).
  2. Engines — compose or feed. Headless, own invariants that cannot be violated (state machines that can't skip states, append-only entries). You either compose an engine (import it; its in-scope functions run in your transaction) or feed it (emit a fat event; it consumes — no import). Engines never import each other. Read an engine's real surface from node_modules/@substrat-run/engine-*/dist/index.d.ts — never guess at it.
  3. Your vertical — everything a user touches. Vocabulary, price list, extra fields, roles, screens. If your core noun isn't something an engine already owns, this is most of the app — a normal, supported outcome.

Project layout

The linter and tests expect this shape. manifest/migrations/module are module code (the rules below bind them); seed/server are harness (exempt).

src/entities.ts        defineEntities — WHAT EXISTS                ← module code
src/operations.ts      defineOperations — the declared surface     ← module code
src/manifest.ts        moduleManifest.parse({…}) + PERM consts   ← module code
src/migrations.ts      the SqlMigration[]                         ← module code
src/module.ts          the handlers, bound to the declaration      ← module code
src/provision.ts       MODULES, ROLES, grant shapes — node-free    ← module code
src/seed.ts            host, tenants, demo cast, seed world        ← harness
src/personas.ts        the dev cast, read by the issuer and the seed ← harness
src/routes.ts          the routes, DERIVED from the operations     ← harness
src/server.ts          the dev entrypoint (node + persona picker)   ← harness
src/worker.ts          the deployable Cloudflare worker             ← harness
src/config-do.ts       per-instance config store (Cloudflare only)  ← harness
test/scenario.test.ts  the scenario — including the denials
test/entities.test.ts  the registry, held to the tables it migrates

A new route is an http declaration on its operation, never a handler in an entrypoint. src/routes.ts holds no table: mountOperations (from @substrat-run/vertical-host) derives one from the http each operation in src/operations.ts declares — method, path, and which input fields the path carries, compile-checked there — and both server.ts and worker.ts mount that one derivation, so a route is live on both the moment it is declared. A route added to only one entrypoint is a surface that works in dev and 404s in production (or the reverse), which nothing catches until you deploy: the scenario tests call operations directly and never boot either host. A composed engine's operations carry no http of their own (an engine does not own a URL shape), so the vertical binds them with defineEngineRoutes beside its own declarations. What an entrypoint may still own is only what is genuinely its own — building a host, resolving a caller, and its own auth-shaped routes (/api/auth/* and /api/me in dev, /api/me in the worker).

provision.ts is deliberately node-free: both hosts register from it (the dev server's SQLite host and the worker's ScopeDO), and substrat push reads the permission registry from it (package.json substrat.permissions). Roles or modules defined anywhere else will run locally and silently not deploy. worker.ts mounts the platform's /internal/* management contract via mountPlatformSurface from @substrat-run/vertical-host (one call — the routes and the { error } envelope are authored there, not here, so they can't drift or ship half-done). What worker.ts still owns is the auth seam — it resolves nobody until you wire real auth there, and every /api/* call is 401 until you do. That is deliberate: there is no dev header to forget to turn off. src/server.ts shows the shape, signing in against the local OIDC issuer pnpm dev starts.

A UI ships as declared assets. If app/ exists, substrat.runtimeNeeds.assets must point at its build output and runtimeNeeds.build must produce it — otherwise the deployed vertical serves the API and 404s on /, with every local gate green. Declare it in the same change that creates app/, not at deploy time. (substrat push refuses an undeclared UI, but by then you are already deploying.)

Among the hooks it passes, onConfigure is the one you must not drop. It is how per-instance settings reach the running app: the dashboard's Settings → Env and Identity tabs POST to /internal/configure, and a vertical that supplies no hook answers 501 to that call for its whole life — the setting is saved, the dashboard reports delivered: false, and the app never sees it. That includes the substrat:auth issuer choice, i.e. the difference between a working login and 401-on-everything. The starter stores deliveries in config-do.ts and reads them back through resolveScopedEnvSpec (instanceConfig). Read settings that way and never off env directly: an envSpec default rides as a worker binding shared by every install of one serving script, so env.FOO is the same string for every tenant no matter what any of them saved. Declare a setting once, in src/manifest.ts (SHOP_ENV) — src/provision.ts re-exports it as envSpec, which is what substrat push uploads; package.json carries no copy.

Declare what exists, then implement it

A vertical declares its entities and its operations in two typed modules, and the compiler checks the joins between them. This is not documentation of the code — it is the code's other half, and the reason a whole class of mistake stops being something a reviewer has to catch.

src/entities.ts says what exists. Each entry names the table, the row shape as ctx.sql returns it (snake_case included — a prettier second naming is exactly the second description this removes), its natural key, its parents (the permission walk), and which fields are erasable:

ts
export const bikeShopEntities = defineEntities({
  customer: {
    table: 'shop_customers',
    fields: z.object({ id: z.string(), number: z.string(), name: z.string(), … }),
    key: ['number'],
    erasable: ['name', 'phone'],
  },
  bike: { table: 'shop_bikes', fields: …, parents: ['customer'] },
});

Not every table is an entity. An entity is a thing the platform can point AT — attachments hang off one, grants narrow to one, events are about one. shop_price_list is keyed by article, has no id and is never the subject of an EntityRef, so it is deliberately absent and its shape lives beside the operations that return it.

src/operations.ts says what each operation accepts, answers with, and is gated by — against those entities and a declared list of permission keys:

ts
export const bikeShopOperations = defineOperations(bikeShopEntities, SHOP_PERMISSIONS)({
  'shop/create-customer': {
    summary: 'Register a workshop customer',
    permission: 'customer:manage',
    input: z.object({ number: z.string().min(1), name: z.string().min(1) }),
    output: bikeShopEntities.customer.fields,   // from the registry, not restated
  },
});

Then src/module.ts holds only the bodies, bound to that declaration:

ts
} satisfies OperationImpl<typeof bikeShopOperations, OperationContext>;

Four things are now compile errors at the exact method: a handler whose input disagrees with the declared input, one whose return disagrees with output, an operation declared and not implemented, and one implemented and not declared. The same object feeds operationInputs: operationInputsOf(bikeShopOperations), so the schemas the host parses with are the ones the declaration states — they cannot drift, because there is only one of them.

The permission list works the same way. SHOP_PERMISSIONS, the array handed to defineOperations, is also the keys that src/provision.ts hands definePermissions({ modules, roles, entityGrants, keys }) — and definePermissions throws at module load if those keys and MODULES disagree in either direction. Hand both readers the same array; a second copy is a list that drifts.

A list read must declare paged. defineOperations refuses a bare-array output that does not, and it refuses it at module load — so it fires in every build, every test and every dev server rather than in a lint tool that has to find you. A list endpoint returning the whole table is a bug with a delay on it: it passes review, it passes tests, and then one tenant's table gets large. Declare the entry as output and let paged wrap it; the handler returns Page<Entry>, which pageOf builds. Either the kernel composes the walk (paged: { over: { entity: 'customer', sortable: ['number'] } }) or, where it cannot — a kernel table, a per-row permission walk, a table the registry does not carry — the handler composes its own and names the field the cursor walks (paged: { sortKey: 'article' }). Keyset, never offset: on live data an offset shifts between requests, so pages drop and duplicate rows.

A paged read has an HTTP half, and the derived route does it for you. The operation answers with a Page<T> — it is transport-agnostic, and a test or a seed must be able to walk a list with no response to read headers off — so the projection happens at the edge: mountOperations forwards the page trio in (limit, cursor, order, parsed with the platform's own defaults and ceiling) and hands the entries back as the body with the walk in a Link header. Declare http on the paged operation and both halves are there; hand-mount a paged read yourself and you have to do both by hand — forget the first and the endpoint is pinned to page one no matter what the operation supports, forget the second and it answers with an envelope where it used to answer with an array. That is the case for not hand-mounting anything an operation can declare.

The rules (non-negotiable)

Module code = everything reachable from a ModuleRegistration (operations, consumers). Rules 1–5 are enforced mechanically by boundary-lint.

  1. Data access is ctx.sql only. Never import better-sqlite3, an adapter, node:*, or cloudflare:workers in module code. That last one is not a style rule: it exports an ambient env, so a single import hands module code every binding and secret your worker declares — including its own SCOPE Durable Object namespace, which reaches another scope's data. ctx.sql is closed over one scope and cannot. Capabilities arrive on ctx; DurableObject is imported in harness code (worker.ts, *-do.ts), never here.
  2. No fetch / network in module code. It would hold the scope's transaction open on a third party. The sanctioned path is a connector: emit a fat event, register a handler that runs outside the transaction. An integration is never impossible because of this rule — it has an answer.
  3. Never write _substrat_* tables. Reads are fine (timelines are projections); writes forge the audit spine. Do the read with readTimeline / readHistory from @substrat-run/kernel rather than a SELECT of your own: both take an EntityRef, page like a list read, and decode the envelope for you. readHistory also returns the payload, the authorization chain (which checks the operation passed, and under which grant), the impersonation stamp, the PII class, the operation the event was emitted from (the exact invoke() string) and the version the emitting code was deployed as. On all but the PII class, a null is a fact rather than data you failed to fetch — the payload was erased, the row predates authorization recording, nobody was impersonating, a consumer emitted it (or the row predates the column), no version identity was present — so render it as that. version comes from the outbox column only, never the envelope, so this helper is the one way to join an event to the push that wrote it. Neither helper checks a permission; you do, before you call it.
  4. Another module's tables are private. Never SELECT from workorder_* etc. — use the engine's exported in-scope functions. This is the rule with no runtime equivalent: the shortcut works and silently welds you to an engine's private schema forever. Need extra data on an engine entity? Add your own side table keyed by the engine's id — never a column upstream.
  5. Time comes from ctx.now(). Module code has no other clock — new Date() and Date.now() are banned exactly like node:*. It is the same instant for the whole operation, so your rows and the events announcing them agree about when. Store it as ISO text, never an epoch integer. Because the host injects the clock, a scenario can test elapsed time (manualClock from @substrat-run/kernel) instead of sleeping or shrinking the window to zero — the workaround that proves nothing.
  6. Every operation checks a permission first. assertAllowed(await ctx.check(PERM)) is the first line.
  7. Every mutation emits a fat event — a consumer must never need a cross-module read.
  8. Never fork an engine. Extend by composition. If you must fork, the engine drew its line wrong — that's design feedback, not a coding problem.
  9. IDs are ulid(). Money is strings via @substrat-run/contracts helpers (moneyOf, mulMoney, addDecimal, compareDecimal) — never floats.
  10. Web-standard APIs alwaysglobalThis.crypto, TextEncoder, URL. Never hand-roll a hash to dodge an import ban.
  11. Parse, don't trust — and the host is what parses. A module passes operationInputs: operationInputsOf(ops) beside its operations, and every invocation is parsed against the declared schema before the guards and the handler, on every path in (HTTP, test, seed, schedule). Handlers do not hand-parse; a declared input nobody parses stops being possible rather than merely discouraged. Import z from @substrat-run/contracts, never from zod. Zod schemas don't compose across copies or majors; composing a contracts schema into one built from a separate zod fails at runtime (expected a Zod schema) with an error pointing nowhere near the cause.

Swallowing an engine error requires ctx.atomic

An engine call composed inside your transaction has no boundary of its own. A catch that handles the failure and carries on therefore leaves you holding the engine's partial writes — the rows its invariants were protecting — and then commits them. Give the call a boundary instead:

ts
try {
  await ctx.atomic(() => completeWorkOrder(ctx, { orderId, billable }));
} catch {
  // the engine's rows, events, links and grants are all gone; your own writes
  // survive, and the operation still commits once
}

A succeeded ctx.atomic is still provisional: if the operation later throws, its writes go too. Sub-transactions nest but must not interleave — starting two concurrently throws.

The line is whether the failure still reaches the caller. A catch that swallows an engine error — one that does not rethrow — is what needs the boundary, and outside ctx.atomic boundary-lint rejects it with no escape hatch. A catch that always rethrows (catch (e) { log(e); throw e }) needs nothing, and neither does try/finally with no catch: the operation still fails and the whole transaction rolls back, which is already the outcome the rule protects. ctx.atomic is what you reach for when you intend to continue past the failure.

"Always rethrows" is read literally — the catch's last statement is the throw. A throw buried in an if block is not that, because the catch runs on past it.

entityRelations in the manifest must declare every edge you traverse — both your own (bike → customer) and the ones an engine makes on your behalf (workorder → bike). The adapter rejects a ctx.link for an undeclared edge, so a missing one fails loudly. This is also what lets a portal permission-walk reach the owner.

Sharing is ctx.grant / ctx.revoke, not a table

When a person shares their own record with another person — and takes them off it again — the operation narrows a permission it already holds onto that one entity:

ts
await ctx.grant(principal, PERM.listContribute, listRef(listId));   // share
await ctx.revoke(principal, PERM.listContribute, listRef(listId));  // un-share

Entity-required (module code can never write a scope- or tenant-wide grant), delegating (the caller's own decision on that entity is re-checked, so an operation can never hand out more than it holds), and transactional with the operation. Every later ctx.check reads the grant, so nothing else has to remember who may touch what.

Neither alternative is this, so you can tell a real absence from this one: a ctx.link edge is not revocable at all — it is permanent — and org membership is revocable but coarse-grained — a whole org, not one record. Never mint an org per domain row, or a membership table consulted by hand in every handler, to get a revoke. The two-line reference is the todo demo (src/module.ts, todo/share-list and todo/revoke-share).

The gates — run them, believe them

sh
npm test                        # the scenario, including the denials
npx @substrat-run/boundary-lint # the layer rules (1–5)
npm run typecheck

boundary-lint exits non-zero if it couldn't do its job (no module code found, no engines resolvable) — a pass that checked nothing is worse than no linter. Never wave that through; fix the setup until it can see your code.

A green scenario test does not mean the app works: the test calls operations directly and never exercises server.ts, its routes, or the principal picker. Before calling a vertical done, boot the server and drive the real flow over HTTP as two personas — one who should succeed and one who should be denied — and confirm the denial arrives as a denial (not a generic error).

.claude/launch.json is what starts those servers for an agent with a browser pane, and it declares a port — never a path or a query. So the pane opens each server's bare origin, and no entry can deep-link a page underneath it: reaching one is a navigation you perform once the preview is up, not something configuration expresses. Do that without being asked. The page worth opening first on a vertical that serves the Scalar API reference is /api/docs — every operation, rendered from that vertical's own /openapi.json, on the vertical's own origin, so a try-it request carries the session cookie you already have and a 403 in the playground is the permission system working rather than a broken page.

When it breaks — symptom → fix

Six failures that have each cost someone a day. What makes them expensive is that none of them names its own cause: the symptom points somewhere other than the fix, so an agent debugging from first principles walks away from the answer.

SymptomFix
expected a Zod schema at runtime, pointing nowhere usefulTwo copies of Zod. Never add zod to package.json — import z from @substrat-run/contracts, so the schema you build and the one the host validates with are the same class.
Killing the dev server kills unrelated ones toopkill -f 'tsx src/server.ts' matches every Substrat project running on the machine, not just yours. Kill by port instead: kill $(lsof -ti :<port>).
Green locally, red in CI, with nothing in the diff that explains itA warm build output hides it. Delete dist, reinstall from the lockfile (--frozen-lockfile), and re-run the gates before believing a local green.
Green test suite, broken appThe scenario calls operations directly and never reaches server.ts, its routes, or the principal picker. Boot the server and drive the flow over HTTP as two personas — one who should succeed and one who should be denied.
Permission denied after you widened a roleRoles are projected into a scope when that scope is provisioned, from ROLES in src/provision.ts. An existing scope keeps the projection it was born with — re-provision it (or re-seed onto a fresh data directory), then present the permission diff below.
pnpm install dies compiling better-sqlite3Take better-sqlite3 out of pnpm.onlyBuiltDependencies. Since 13.x it ships prebuilt binaries and no install script, but it still ships a binding.gyp — so an allowlist entry makes pnpm run a node-gyp rebuild that nothing here needs.

Two human checkpoints — you may never self-approve

Present these and stop:

  1. Migration diff — every new SqlMigration, verbatim. Migrations are append-only forever once shipped, so this is the last cheap moment to change your mind.
  2. Permission diff — a table: key → description → which roles hold it → why. Walk the reviewer through it in their own vocabulary until they can answer who can now see the money, and who can see other tenants' data? A permission diff nobody understands is theater — it reproduces the exact failure Substrat exists to prevent.

The hard parts, hosted.