Skip to main content
Wildo.ai Coming soon

Foundations

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> and wildo compose <scenario> --var name=value [--dry-run].
  • A new scenario: a scenario.json with files/ and edits.
  • The exported anchors in wildo.saas.config.ts a scenario appends to.
  • layout.refs, layout.edit, layout.display and layout.summary in a *.ui-behavior.tsx.
  • A template.tsx and labels.<locale>.ts pair under backend-api/src/engine/email/system/<name>/ or a module’s emails/resources/<resource>/<operation>/.
  • z_file({ generation }) on the schema, a PdfTemplateDefinition, and definePdfTemplateBindings on the module’s backend descriptor.
  • defineWebsiteSection and the locale packs under website/src/i18n/.
  • wildo.infra.<env>.config.ts for each deployment target.
  • wildo generate labels and wildo generate translate after naming a new slot.
For engineers

How it is built

One rule, eight families

FamilySourceRendered byLands in
Application skeletonskeletonwildo initthe application root
Composition scenarios<ref>wildo compose, foundation executora module, service or package
Infrastructure**the deployment services.wildo-saas/infrastructure/, manifests, CI workflows
Screen layoutsZod schema, *.ui-behavior.tsxthe automated layout generatorforms, detail and summary views
Emailtemplate.tsx + labels.<locale>.tsthe email template registrysent mail
PDFPdfTemplateDefinition bound per fieldthe PDF generation orchestratora generated file on the record
Website sections, documentation unitsdefineWebsiteSection, documentation unit schemathe website and documentation renderersthe marketing site, the documentation portal
Labelsplanned labelling unitsthe companion’s labelling pipelinebackend-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-provider writes 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.MARKETING to 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-locale adds 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.

Building a B2B product or an internal tool?

Wildo is not self-service yet. Tell us what you have in mind and we will say plainly whether it fits, and what happens next.