Templates and composition
Every piece of a Wildo application starts from a template, and every template knows where its output belongs. A new application is rendered from a skeleton; a new module, resource, website, documentation site or background worker is added by applying a named scenario; the screens, emails, documents and marketing pages of the application are rendered from layout, email, PDF and section templates that the framework holds to a contract. The leverage is not that files are copied for you. It is that an artifact’s location and its registration follow from what it is, so neither a person nor a coding agent has to decide where something goes, and the framework can check that everything it expects to find is there.
The idea
The word “template” names several things in Wildo, and they share one rule, recorded in
.claude/rules/application-creation-directive.md: placement is determined, never improvised. An
artifact’s location is computed from its kind (a family maps to a directory; a module task maps to
that module’s paths), so the author is told where things go rather than asked. What you declare
once is small: an application’s identity and ports, a module’s identifier, a resource’s name, the
label keys a website section expects, the template id a generated document carries. What is derived
is large: whole file trees, registry entries located by syntax tree, dependency declarations,
translation keys, manifest bindings, and the checks that refuse drift between them. Where a template
writes into an existing file it does so idempotently and never over content you changed: git is the
merge and the undo, and the framework keeps no ledger of what it wrote.
What you get for free
- A bootable application from one command, with ports allocated so two applications coexist.
- Registration for everything a scenario creates: module registries, service entries, provider scopes, workspace members, dependency and peer declarations, override entries.
- Dry-run plans that are the exact bytes the apply will write.
- Idempotent re-runs, and a refusal rather than a merge over anything you edited.
- A working form, detail and summary screen for every resource before any layout is written.
- Translation keys for every section, wizard step, group, email slot and website slot you name.
- A locale fallback chain for emails and a startup warning for any missing system email.
- A regenerate operation, a specification entry and a startup identity check for every generated document field.
- Build-time refusal of a marketing site whose label packs are incomplete, and of a documentation portal whose publication does not exist.
Where you plug in
wildo init <name>andwildo compose <scenario> --var name=value [--dry-run].- A new scenario: a
scenario.jsonwithfiles/andedits. - The exported anchors in
wildo.saas.config.tsa scenario appends to. layout.refs,layout.edit,layout.displayandlayout.summaryin a*.ui-behavior.tsx.- A
template.tsxandlabels.<locale>.tspair underbackend-api/src/engine/email/system/<name>/or a module’semails/resources/<resource>/<operation>/. z_file({ generation })on the schema, aPdfTemplateDefinition, anddefinePdfTemplateBindingson the module’s backend descriptor.defineWebsiteSectionand the locale packs underwebsite/src/i18n/.wildo.infra.<env>.config.tsfor each deployment target.wildo generate labelsandwildo generate translateafter naming a new slot.
For engineers
How it is built
One rule, eight families
| Family | Source | Rendered by | Lands in |
|---|---|---|---|
| Application skeleton | skeleton | wildo init | the application root |
| Composition scenarios | <ref> | wildo compose, foundation executor | a module, service or package |
| Infrastructure | ** | the deployment services | .wildo-saas/infrastructure/, manifests, CI workflows |
| Screen layouts | Zod schema, *.ui-behavior.tsx | the automated layout generator | forms, detail and summary views |
template.tsx + labels.<locale>.ts | the email template registry | sent mail | |
PdfTemplateDefinition bound per field | the PDF generation orchestrator | a generated file on the record | |
| Website sections, documentation units | defineWebsiteSection, documentation unit schema | the website and documentation renderers | the marketing site, the documentation portal |
| Labels | planned labelling units | the companion’s labelling pipeline | backend-api/src/engine/i18n/<bucket>/<slug>.<locale>.ts |
The first three are Handlebars templates rendered to files once. The others are code and data the running application renders on every request or at build time. Both kinds obey the placement rule.
The application skeleton
wildo init materialises skeleton through SaasSkeletonService in
saas-skeleton.service.ts. The tree carries
three classes of file: plain files copied verbatim, *.hbs files rendered through the shared
Handlebars TemplateProcessor with the application identity and a block of allocated ports (two
applications beside one framework checkout must not collide), and a gitignore written out as
.gitignore, because a real dotfile inside the template tree would make git ignore the template’s
own files. A .init.hbs suffix marks the initial variant of a file whose later evolution belongs to
a service or a scenario: wildo.saas.config.ts.init.hbs is rendered once, and afterwards the
configuration service and the scenarios own it.
The rendered configuration exports empty objects on purpose (applicationWorkers,
applicationMinions, applicationExtraServices, frontendServiceProviderScopes and their scope
twins). They are insertion anchors located by syntax tree, so that a later scenario appends a worker
bucket and its provider scope in one apply and the pair can never drift. Two files are derived
rather than templated: the root package.json, whose pnpm.overrides are computed from the
framework’s current override set, and the development stack script, whose package list is the set of
scaffolded workspace members. Every generated package declares the engine’s peer closure, read from
the delivered engine-peer-closure.json, so a later version pin can reach it. The service refuses a
non-empty directory, and runs in two modes: SkeletonMode.FRAMEWORK_DEV links the framework
checkout, SkeletonMode.APP_CREATOR pins published @wildo-ai/* versions.
Composition scenarios
A scenario is a directory under scenarios whose name is its ref, holding a
scenario.json manifest and an optional files/ tree. The manifest, validated by
CompositionScenarioManifestSchema, declares ref, version, title, description, typed
variables (string, number or boolean; required or defaulted; optionally pattern-constrained),
edits over existing files, and postNotes printed after a successful apply for the steps the
scenario cannot automate. Inside files/, a .hbs file is rendered through
createCompositionTemplateRenderer (an isolated Handlebars environment with nine helpers, so both
hosts render identical bytes), any other file is copied as raw bytes, and __variable__ path
segments interpolate from the same values, which is how __moduleId__/index.ts.hbs becomes
crm-core/index.ts.
Edits over existing files are the part that makes a scenario more than a copy. The engine offers
eleven kinds, named in CompositionScenarioEditKind, and every one is anchored by syntax tree or by
structure rather than by regular expression: ts-array-append, ts-import-add, append-line,
ts-enum-member-append, ts-object-append, append-block, json-object-append,
pnpm-override-append, package-dependency-add and config-property-add, the last reaching a nested
path inside wildo.saas.config.ts through the configuration walker. Each kind is idempotent, and
each refuses when the key it would write already exists with a different value: authored content is
never silently overwritten. This is drawn from scenario.json:
{ "kind": "ts-import-add", "file": "shared-lib/src/modules-registry.shared.ts",
"statement": "import {{moduleCamel}}Module from './modules/{{moduleId}}';" },
{ "kind": "ts-array-append", "file": "shared-lib/src/modules-registry.shared.ts",
"exportedArray": "applicationSharedModules", "element": "{{moduleCamel}}Module" },
{ "kind": "append-line", "file": "shared-lib/src/index.ts",
"line": "export * from './modules/{{moduleId}}';" }
The apply engine in
composition-scenario-apply.application-creation.service.ts
is split into planCompositionScenario, which computes every byte without touching the tree, and
applyCompositionScenarioPlan, which writes an already approved plan; --dry-run falls out of the
split. Three invariants hold. Unknown, missing or malformed variables refuse before any file is
read. A target that exists with different bytes puts the whole apply into refusal and nothing is
written, while a byte-identical target is skipped, so re-running a scenario is idempotent. And a
written file belongs to the application from that moment: the engine records nothing, and a later
run against edited output refuses rather than merges. Dependency declarations are part of the plan,
not a pass after it. While planCompositionScenario renders a package.json the scenario creates,
it expands the engine peers that manifest inherits, read from the delivered engine-peer closure
under .wildo-saas/templates/, so the preview shows the derived peerDependencies and a collision
on that file is judged against the bytes that will actually land. wildo compose then folds
provider package links into the same plan before it prints anything: the root manifest’s derived
pnpm.overrides are computed against the prospective files, without a temporary tree, and an
existing root that differs refuses like any other target. That provider step is CLI-owned; the
companion’s foundation executor applies the same core plan and does not run it.
Scenarios resolve from two roots: the application’s delivered copy under
.wildo-saas/templates/scenarios/ first, the framework checkout second, and per ref the delivered
copy wins, because it is what the application’s framework version shipped. Fifteen scenarios ship
today: add-module, add-resource and add-custom-operation grow the domain; add-website,
add-website-section, add-marketing-page and add-locale grow the marketing site;
add-technical-doc adds the documentation portal; add-minion, add-worker-node and
add-worker-rust add background processes; add-environment adds a deployment target;
add-backend-provider, add-file-picker-provider and add-provider declare providers. The operator
surface is wildo compose <scenario> --var name=value [--dry-run] in
compose.command.ts.
The creation journey uses the same engine. deriveFoundationCompositionPlan in
foundation-composition-plan.application-creation.service.ts is a pure function from an approved
domain plan to an ordered list of add-module and add-resource invocations, plus the engine
capability change the chosen tenancy implies; it refuses any name it cannot derive into the
scenario’s declared variable patterns. The companion’s foundation executor supplies the host facts
and applies each invocation through the same renderer wildo compose uses, so identical scenario
bytes produce identical files whichever door applied them.
Modules: how an application is organised
A module is the unit of organisation, and it is a directory of the same name in each package:
shared-lib/src/modules/<id>/ (the Zod schemas, resource configurations, relationships and field
identifiers), specifications/src/modules/<id>/ (the module’s business semantics and its resource
specifications), backend-api/src/modules/<id>/ (operations, addons, emails, PDF templates) and
frontend/src/modules/<id>/ (the UI behaviour and views). Three registries are static and are
appended by syntax tree: applicationSharedModules in shared-lib/src/modules-registry.shared.ts,
applicationModuleBusinessSemantics in specifications/src/module-registry.specification.ts, and
applicationFrontendModules in frontend/src/modules/index.ts, and a fourth entry declares the
module to the application itself: add-module appends the module key, with its explicit
services list, to the applicationModules object wildo.saas.config.ts consumes, because a
module registered in source but absent from that declaration is missing from the bootstrap and
provider-runtime descriptors. The backend registry alone needs no edit: its modules/index.ts
discovers the default export of every module directory’s EMITTED index through
scanSubdirDefaultExports, so a source folder becomes a runtime module only once the compiler has
published it, and a custom operation is likewise found by a recursive scan for .operation files,
which is why add-custom-operation performs zero edits. Discovery supplies a service’s
contributions; it does not decide which services a module belongs to. That is the declaration’s
job, and a worker is a separate service binding rather than another name for the backend.
Wonder Todos shows the three sibling registries the framework then assembles:
modules-registry.shared.ts,
modules-registry.frontend.ts and
modules-registry.backend.ts, each prepending the engine’s own
module to the application’s. The backend descriptor a module exports (BackendOwnedModule) is the
busiest insertion point: later scenarios and workflows append seeds, flowsActors,
pdfTemplateBindings, chartDefinitions and emailTemplateDefinitions beside backendModule.
add-resource lands a starter schema, a resource configuration factory and a resource specification
inside the module, then appends the resource to the module’s typed registries, including an enum
member located by syntax tree.
Infrastructure templates
*.hbs, the forty-one manifests under ** and the
workflow templates under ci are rendered by the deployment services with the
same TemplateProcessor the skeleton uses. renderDockerComposeTemplate in
docker-compose-generator.service.ts
renders the platform stack and the per-application file; K8sManifestGenerator.renderAllManifests,
invoked from k8s-bootstrap.service.ts, renders namespace, ingress, certificates, storage, the
internal backing services and the per-application deployments, with every template variable typed
in k8s-manifest.schemas.ts; k8s-secrets-generator.service.ts renders secrets;
ci-workflow-generator.service.ts renders one deployment workflow per environment. All of them read
the same environment descriptor, wildo.infra.<env>.config.ts, which add-environment creates and
wildo config sync discovers by directory scan. The Compose lane runs the dogfood stacks; the
Kubernetes lane is implemented and authored by a dogfood environment but has no recorded cluster
deployment, which is the maturity environments-and-services records.
Layout templates for generated screens
A resource’s create, edit, detail and summary screens are rendered from a layout template. When you
declare none, generateDefaultFormTemplate, generateDefaultDisplayTemplate and
generateDefaultSummaryTemplate in
default-layout-generator.frontend.utils.tsx
derive one from the Zod schema: a flat list of <FormField> inside <FormLayout>, conditional
fields from .showWhen() decorators, a <DiscriminatorSwitch> for discriminated unions and an
<ObjectScope> for nested objects. When you want sections, wizard steps or groups, you write the
template in the resource’s *.ui-behavior.tsx through resourceUIBehavior, with the skeleton
components <Section>, <WizardStep> and <Group>. This is the shape in
tasks.ui-behavior.tsx:
layout: {
refs: { sections: ['content', 'workflow', 'effort', 'assignment'] },
edit: () => (
<FormLayout>
<Section name="content" appearance={SectionMode.CARD}>
<FormField name="title" />
<FormField name="description" />
</Section>
<Section name="workflow">
<FormField name="status" />
<FormField name="priority" />
</Section>
</FormLayout>
),
},
layout.refs is the LayoutRefManifest: a static list of the section, wizard-step and group names
the JSX uses, readable by tooling without parsing JSX. It produces the label keys
resources.{type}.sections.{ref}.title and .description, and their wizard-step and group twins,
which the labelling pipeline plans and useResourceLabels resolves. The resource specification
documents the same slots, and validateStructuralSlotDescriptions in
resource-specification.schemas.ts refuses at load time a
documented slot the layout does not declare, or a declared slot the specification does not document.
Email templates
An email is a directory: template.tsx exporting an EmailTemplateDefinition<L> with
subject(props) and body(props) (a React Email tree composed from the shared primitives in
email-components.ts), beside one
labels.<locale>.ts per locale typed by the template’s label generic. Placement is the key: a
system email lives at backend-api/src/engine/email/system/<name>/ and is keyed
system.<name_underscored>; a resource email lives under the module at
emails/resources/<resource>/<operation>/ and is keyed email.<resource>.<operation>. The
skeleton’s email-template-definitions.ts walks both trees at startup with
scanEmailTemplateDirectory, and EmailTemplateRegistryBackendService.resolve() picks the exact
locale, then the base language, then the configured fallback. The twelve system emails an
application needs are the closed vocabulary SystemEmailTemplateRef; startup warns when one is
missing in the default locale. Each system template also carries an authored specification overlay
(system-email-template-specification.schemas.ts) stating its purpose and translatable slots, so a
coding agent or the companion can draft a body without inventing wording. Wonder Todos carries the
full system set and its module emails under the same two conventions.
PDF templates
A generated document is a file field the application renders itself. The shared schema says only
z_file({ nature: FileNature.PDF, generation: { source: FileGenerationSource.PDF_TEMPLATE, templateId } }), and zod-file.ts refuses every option that would
contradict generation: multiple files, a non-overridable field, a MIME type other than PDF,
retrieval ingestion or upload scanning. The template itself is backend-only:
PdfTemplateDefinition in pdf-template-definition.ts
carries id, optional locale-keyed labels, a buildContext() hook that is the only place allowed
to read data, and a body() returning a React-PDF document. It is bound to its field at startup by
resource type and field name, as in
pdf-template-bindings.ts:
export const tasksManagerPdfTemplateBindings = definePdfTemplateBindings({
[TasksManager_ResourceType.TODOS]: {
generatedSummaryPdf: {
template: todoListSummaryTemplate,
},
},
});
Startup validation checks template.id against the schema’s templateId, so the schema, the
specification (ResourcePdfTemplateSpecification), the derived regenerate operation and the backend
import all name one template. PdfGenerationOrchestratorBackendService renders on create; an
ordinary update never re-renders; the derived GENERATED_FILE_REGENERATE operation replaces the file
explicitly and soft-deletes the one it replaced.
Website sections and documentation units
A marketing page is an ordered array of sections, and a section is declared with
defineWebsiteSection from define-website-section.ts. From
landing.sections.tsx:
export const LANDING_HERO_SECTION = defineWebsiteSection<Record<string, never>>({
sectionRef: 'landing-hero',
category: WebsiteSectionCategory.HERO,
expectedLabelKeys: [
landingKey('landing-hero', 'eyebrow'),
landingKey('landing-hero', 'headline'),
landingKey('landing-hero', 'subheadline'),
],
Component: LandingHeroSection,
});
expectedLabelKeys are the fully composed keys website.<page>.<section>.<slot>, and one flat
JSON pack per locale under website/src/i18n/ must carry every one of them: label-pack.validator.ts
checks the packs against the page manifests, and the loader in label-pack-loader.ts throws on a
missing or malformed pack at build and server-render time. add-website-section writes a section
block before the page’s <PAGE>_SECTIONS array, appends the constant to that array and the two
starter keys to en.json, and leaves the other locales to you; the build names every gap.
add-marketing-page creates a page vertical, add-locale a website locale, add-website the site.
Documentation is data before it is pages. A unit is validated by
TechnicalDocumentationUnitV1Schema in
technical-documentation-units.schemas.ts:
a kind (orientation, quickstart, concept, how-to, reference, runbook, troubleshooting, change),
reader audiences, ordered sections, links, provenance, applicability requirements, an access class
and an editorial status, each list unique and sorted. The engine ships an authored bundle of units
for what it provides; the companion derives the application’s own facts and publishes the rendered
result into technical-doc/src/generated/consumer-documentation/, which the portal scaffolded by
add-technical-doc mounts and refuses to build without.
Label files generated per unit
Every label an application shows is looked up, and the files that hold them are generated per unit.
wildo generate labels and wildo generate translate drive the companion: planAllUnits() in
labelling-planner.companion.service.ts derives a LabellingUnit for each resource, component,
theme, billing catalogue, role set, lifecycle, guidance flow and engine page, with a filePathHint
of bucket and slug, and the orchestrator writes
backend-api/src/engine/i18n/<bucket>/<slug>.<locale>.ts, pruning keys no longer planned. The
backend never generates: scanCustomLabelsDirectory walks that tree at startup and serves it. The
same planner reads a resource’s layout.refs, the label keys a website section expects, and the
per-template email labels, so a slot you name in a template becomes a key that is planned, drafted
and translated without a second declaration.
Boundaries and known limits
- A scenario never merges. A target that differs refuses the whole apply; reconcile in git first.
add-providerwrites a provider scope with empty capability and protocol lists on purpose, and the runtime sync rejects it until you fill them from the provider package’s own declaration.- The default layout generator stops at flat lists: array-of-object fields and conditions on nested fields need an explicit template.
- Generated documents are rendered for MongoDB-backed resources only; the PostgreSQL adapter refuses create-time generation and regeneration because file metadata is stored in MongoDB and cannot be committed with the parent row. Only single-file fields can be generated.
- A template may declare
type: EmailTemplateType.MARKETINGto route through a marketing-capable vendor, but no engine service orchestrates campaigns and no dogfood application enables it. - Label generation is development-time only, and the frontend has no cross-locale key fallback: a key the pipeline never generated shows as its raw path.
add-localeadds a locale to the marketing site, not to the application’s own labels.- The Kubernetes templates are rendered and tested, not yet proven by a recorded cluster deployment.
- Brand propagation to a generated site exists as a command and is not exercised by a dogfood site.