Week 6 · Day 29 of 40 · Build AI

Config-first design, grammars and meta-compilers

You ship: WorkBot in one config-first monorepo, with a second tenant from configuration, a rule language with 20 tests, and an entity generator. Builds on: Day 26, Day 28.

By the end of the day you can

  • Design products where behavior is configuration and code is an interpreter
  • Write a small BNF grammar and a parser for a domain language, and generate code or configuration from it
  • Structure a monorepo so agents and humans can navigate and change it quickly

Topics

Config-First Code Design

  • The split: schemas (what exists), configuration (what this deployment does), code (how configuration is interpreted)
  • Zod schemas type the configuration; validate at startup
  • Levels: product defaults, tenant overrides, user preferences; precedence rules written once
  • Configuration as the agent's workspace: the agent proposes config changes, tests run, a human approves; code changes are rarer and reviewed harder
  • Anti-patterns: configuration that is really code (nested conditionals in YAML); code that is really configuration (hard-coded prompts and routes)

Domain Languages + BNF Grammars

  • When configuration is not enough: rules, workflows, templates, query languages. A small language with a grammar is more testable than a JSON tree of conditions
  • BNF and EBNF: terminals, non-terminals, productions; reading a grammar; writing one for a rule language
  • Parsing in TypeScript: PEG parsers (Ohm), parser toolkits (Chevrotain); parse tree → AST → interpreter
  • Grammars and models: constrained decoding and JSON Schema structured outputs are grammars enforced at generation time; one grammar validates model output and human input

Meta-Compilers + Meta Design

  • Meta-compiler: a description of a language in, a parser or interpreter out
  • The same idea at product scale: a description of a product (entities, rules, screens, tools) that generates the product
  • Generators already in use: ORMs from schemas, API clients from OpenAPI, forms from Zod, UI from json-render catalogs (Day 9)
  • Designing for generation: keep the description small and declarative; irregularity lives in the generator
  • Failure modes: the description grows its own conditionals; generated code gets edited by hand

Monorepo Structure

  • pnpm workspaces + Turborepo: apps (web, api, workers), packages (schemas, config, agent, integrations, ui), tooling
  • One lockfile, one type-check, one CI; task graphs and caching so the whole repository builds in minutes
  • Conventions agents can follow: where things go, how packages import each other, an instruction file per package
  • Shared types end to end: a schema package imported by API, workers and UI

Build

Features shipped today

  • WorkBot: Per-customer configuration, the rule language, and the entity generator; escalation thresholds, source policy and content rules moved to configuration; the move into a config-first monorepo with shared packages

Restructure WorkBot into a config-first monorepo:

  1. Monorepo with pnpm workspaces and Turborepo: apps/web, apps/api, apps/workers, packages/schemas, packages/config, packages/agent, packages/integrations; shared types; one CI pipeline with caching
  2. Config-first: tools, prompts, routes, provider routing (Day 24) and tenant overrides as Zod-validated configuration loaded at startup; bring up a second tenant with configuration only
  3. A rule language: the BNF for the Day 28 rule table (conditions, actions, ordering); a parser with Ohm or Chevrotain; an interpreter; 20 tests. The agent proposes rules in the language, tests run, a human approves
  4. A generator: from a declarative entity description (fields, validations, permissions), generate the Postgres migration, the Zod schema, the API route and the form; add a second entity with only the description
  5. 3 end-to-end tests that stay in CI: a new tenant onboarded from configuration, a rule authored by the agent and approved, and an entity generated end to end

Walkthrough

Make configuration the behavior and code its interpreter, in five moves: configuration in levels with a second tenant from a file, the rules as a language with a grammar, rules the model proposes and a person approves, entities generated from descriptions, and the check as a task graph with a cache. The monorepo the brief draws is written down as the move to make when a second app exists; not making it today is Day 26's decision, not a shortcut.

Part 1

Configuration in levels, one precedence

Study first

  • The split: schemas say what exists, configuration says what this deployment does, code says how configuration is interpreted. A prompt with the company's name in it is code that is really configuration; a YAML with nested conditionals is configuration that is really code.
  • Levels: the product's defaults, a tenant's overrides, the environment, a user's preferences, with the precedence written once. Every level is parsed at startup, so a wrong file or variable fails at startup, with the field or the pair named, rather than at the first request. The rules file is parsed at the same startup and is not a level: it has one source.
  • What the client may know: a client bundle cannot read a file chosen at startup. The server reads the configuration; a client component gets what it needs as props, and a schema that depended on the customer becomes a function of what the server passes.

Build

  1. content/customers/<id>.json, one per customer, with settings for what it overrides; content/defaults.json for the routes and the age a cached page is kept, which the product starts from; CUSTOMER in src/lib/config.ts naming the file a deployment serves.
  2. src/lib/settings.ts: the Settings and Overrides schemas, and resolve(defaults, overrides, env), the precedence in one place: a customer's value replaces the default's, field by field; a route from ROUTES goes first among the models its task tries. src/lib/routing.ts reads the resolved routes and nothing else.
  3. src/lib/request-model.ts loses its import of the customer: requestModel(teams) builds the schemas for a list of teams, RequestDraft is what a client composes, and src/lib/requests.ts builds the real ones from the customer on the server. The catalog takes the teams the same way.
  4. A second tenant from a file: content/customers/harbor-dental.json, with its own teams and assistant, and settings.routes.chat overriding the chat's models. The assistant's name reaches every page from the file, and the chat, a client component, takes it as a prop: 12 places had said WorkBot in code.
  5. Tests: the precedence, level by level; a bad route or task refused at load; a run under CUSTOMER=harbor-dental that checks the name, the teams, the prompt and the overridden route in one go.

Done when

  • CUSTOMER=harbor-dental pnpm build && pnpm start serves Harbor Dental's assistant with no change to code.
A second tenant
Harbor Dental's assistant from a file: the name, the teams and the chat's route, with the code unchanged.
Technical notes
  • The refactor touched 14 files, nearly all import lines: modules that read teams from the model read it from requests.ts, which builds the model from the customer. One action changed shape: the catalog's submit_request keeps its team check only when it is built with the teams, so the client's registry, which never prompts a model, builds it without, and the API checks the team.
  • The eval workflow's trigger paths name content/customers/**, content/defaults.json and src/lib/settings.ts: a prompt is a function of the customer, and a route decides the model.
  • CUSTOMER is read when the app starts, and a page prerendered at build carries the build's customer, so a tenant is a build. The first attempt served the second tenant from the first tenant's build, and the brand on screen said so.
  • Bringing Harbor Dental up found the routing rules naming teams it lacks, and the app refusing to start on them. The rules split: the product's, which name no team, in content/rules.rules, and a customer's beside its file, read after them.
  • User preferences, the brief's third level, are not here: there are no users yet.

Part 2

A language for the rules

Study first

  • When configuration is not enough: a JSON tree of conditions is hard to read in a diff and hard for a model to generate. A small language with a grammar reads in a diff, asks less structure of a model, and one grammar validates a person's line and the model's.
  • BNF and EBNF: terminals, non-terminals, productions; the difference between a lexical rule and a syntactic one (which skips whitespace); keywords kept out of identifiers.
  • Parsing in TypeScript: a PEG parser from a grammar (Ohm), with semantics that turn the parse tree into the objects the program already uses. Constrained decoding is the same grammar applied while the model generates; here it is applied after, by the parser.

Build

  1. The BNF in docs/config.md; the Ohm grammar in src/lib/rule-language.ts, with the keywords as lexical rules and ident defined as a name that is not one of them.
  2. Semantics that build Day 28's rule objects, so the engine does not change; parseRules fails with the line and column; printRules writes a table back as the language.
  3. Two files replace the JSON: the product's 3 rules in content/rules.rules, the customer's routing rules beside its file in content/customers, read after them; the grammar allows comments.
  4. 20 tests: every production, the escapes, the errors with their positions, keywords refused as names, the empty text, the round trip, and the file itself.

Done when

  • The Day 28 table printed and parsed is the same table, and a line with a typo fails at startup with its line and column.
Technical notes
  • In a quoted string, \" and \\ decode to a quote and a backslash; any other escape keeps its backslash, so "\d" is the pattern \d. The printer escapes quotes and backslashes, and what it prints parses back to the same rule.
  • Ohm checks the arity of a semantic action against the production: an action for normalize "phone" takes 2 arguments even though it uses none.
  • The parsed objects are still validated by the Day 28 schema after parsing: ids unique, routed teams real.

Part 3

The model proposes, a person approves

Study first

  • Configuration as the agent's workspace: the agent proposes a change to a file, a check runs, a person approves. The language makes the proposal readable; the parser makes it refusable.

Build

  1. src/lib/rule-proposal.ts: proposeRule asks the model for one line, with the grammar, the fields of each kind, the customer's teams and the rules so far; a line that does not parse, repeats an id or names a team the customer lacks goes back once with the error. dryRun applies the rule alone to up to 100 stored records of its kind, rows the rules already shaped, and lists what would change. approve appends the printed line to the customer's file, since a proposal is the customer's.
  2. pnpm rule "<what the rule should do>" [--approve] in cli/rule.ts.
  3. Tests with a scripted model: the retry with the error, the refusal, the dry run on 2 tickets, the file one line longer. A live test asks the real model for a routing rule and checks it parses and routes to the right team.

Done when

  • A rule the model wrote is in the file, approved by a person after seeing what it would change.
The model writes a rule
pnpm rule: one sentence in, one line in the language out, the 2 tickets it would change, and the line approved into the file.

Part 4

An entity from its description

Study first

  • Meta-compilers: a description of a language in, a parser out; at product scale, a description of a product's parts in, the parts out. Generators already in use here: Zod to JSON Schema for the tools, the catalog to UI (Day 9), the grammar to the parser above.
  • Designing for generation: keep the description small and declarative, put the irregularity in the generator, and never edit what it writes. Two failure modes: the description grows its own conditionals; generated code gets edited by hand.

Build

  1. src/lib/entity-model.ts: a description's schema (name, plural, label, access, fields with type, required, max, multiline, options, default), with the refusals a generator needs: a required field, unique names, a choice's default among its options.
  2. src/lib/entity-generator.ts: what one description becomes, as template strings: the table statement, the module with the Zod schemas and the queries, the route and its types shaped like the hand-written ones, a test that creates from the required fields and is refused without the first, the page with its form and its action. index writes the two files the hand-written code imports: the tables for the schema, the pages for the navigation.
  3. cli/generate.ts: pnpm generate writes the files, formatted with the repository's Prettier, and removes generated files no description asks for; pnpm generate --check fails when a file differs from its description or has none, and runs first in pnpm check.
  4. content/entities/vendor.json, then location.json from a description alone: pnpm generate writes their files, and their generated tests run with the rest.

Done when

  • A second entity exists with only a description written, its route creates and refuses as the generated test says, and a hand edit to a generated file fails the check.
An entity from a description
/admin/vendors, generated from a 24-line description: the form from the fields, the table from the rows, a vendor added through it.
Technical notes
  • The description of locations is 24 lines; what it becomes is 253 lines in 6 files. The Day 26 audit counted 10 files touched for a new request field; a field on a generated entity touches 1.
  • The first version put the table statement in the entity's module, which imports the database, which imports the schema, which imported the module: the statement was read before it existed, the table was never made, and the generated route test was the first to say so. The statements live in a file that imports nothing now.
  • An entity's access decides where its files go: an employee entity's page under /admin and its API under /api/admin, a customer one's at the root, so one check on the admin prefixes covers both. Nothing in the app is authenticated yet; Day 30 adds the check. The first version put every API at /api/<plural>, outside the prefix, and the review caught it; pnpm generate now removes the files a description no longer asks for, so the move left nothing behind.
  • A form field left empty is not sent, so a default applies; the API refuses what the form cannot, with the field named.

Part 5

The check as a task graph

Study first

  • pnpm workspaces and Turborepo: one lockfile, one type check, one CI; a task graph with caching so what did not change does not run again. Conventions an agent can follow: where things go, how packages import each other, an instruction file per package.
  • The monorepo the brief draws, apps and packages, and the question to ask before drawing it: does a second app exist, or a second consumer of a package? One deployable was Day 26's decision, and a package with one reader is a layer, not a boundary.

Build

  1. turbo.json: root tasks for generate:check, lint, typecheck, test and build, the site's lint, typecheck and build, the build's outputs, and the variables the test and build hashes include.
  2. pnpm check as turbo run ...; .turbo cached in CI by commit and restored from the previous run.
  3. Measure cold and warm.

Done when

  • A warm pnpm check with nothing changed finishes in well under a second: here 0.2 s, every task restored, against 17 s cold with the eight tasks in parallel and 32 s for the same work as a sequential chain.
The task graph
pnpm check through Turborepo: the first run does the work, the second finds every task's inputs unchanged.
Technical notes
  • A root task is named //#task in turbo.json, and turbo run lint runs the root's and the site's when both are declared.
  • The root build's outputs name .next without its cache and its development state; the root test and build tasks hash CUSTOMER and DATABASE_URL into their keys, so a change of tenant is a different run. The cold 17 s has Next's own compiler cache warm; the first build on a machine takes longer.
  • The layout to adopt when a second app arrives is written in docs/config.md, with the trigger, so the move is made once and for a reason.

Part 6

Write it down

Build

  1. docs/config.md: the split, the levels, the grammar, the proposal flow, the generator with its counts, the task graph with its timings, what is not here.
  2. Record D18 in brain/architecture/decisions.md, then update the backlog, the README, the agent notes' conventions, and the routing and data-layer documents where they described the table in code or the JSON rules.

Done when

  • A reader can bring up a third tenant, add a rule and add an entity from the document alone, and knows what the monorepo move waits for.

Resources