Skip to content

Inline authoring

Feature registration is written inside the defineFeature callback. The calls to r.entity, r.screen, r.nav, and the other registrar methods remain visible to tools without executing the app. Large entity and screen values may live in schema/ and be imported into feature.ts; the standard scaffold uses that layout.

This keeps the source usable by the designer, code transformations, and AI authoring tools. Do not hide registration behind a function that conditionally builds a different feature graph at runtime. Handler bodies and pure helpers belong in their own files.

written out in plain sight hidden behind a builder
"fields: title, severity" vs buildTicket(options) -> ???
a tool can read it directly a tool has to run code to find out

A Kumiko app feature is written inline. Entities, handlers, hooks, events, screens, they live as literal expressions inside the defineFeature callback, not behind a factory function that constructs them at runtime. This looks like a stylistic choice. It is a contract, and it has a specific reason.

// Visible registration, the standard app-feature shape
export default defineFeature("incidents", (r) => {
r.entity("incident", incidentEntity);
r.writeHandler(resolveIncident);
r.queryHandler(listOpenIncidents);
});
// Factory, what app features must NOT look like
export function createIncidentsFeature(opts: { auditEnabled?: boolean }) {
return defineFeature("incidents", (r) => {
r.entity("incident", buildIncidentEntity(opts));
if (opts.auditEnabled) r.requires("audit");
});
}

The first form leaves the registrar calls visible. The second makes the feature shape depend on runtime options.

Three classes of tooling depend on the inline form, and each of them fails on the factory version.

The AST patcher. When Kumiko’s designer adds a field to an entity, it parses the source file, finds the r.entity("incident", incidentEntity) call, and follows regular source references into the schema module. A factory call whose result depends on runtime options has no stable shape for the patcher to edit.

The designer. A non-developer rendering a feature in the schema designer sees one entity per r.entity call. The designer reads the source AST, not the runtime registry, because the runtime registry only exists after a build. A factory function hides the entity behind one or more layers of indirection that the designer cannot follow without running the code.

The AI feature builder. An LLM generating a feature edit needs the input to be the same shape as the output. If the codebase is full of factories, every example becomes “decode the factory’s options object, imagine what it produces, edit the imagined version”. Generating the code becomes generating the inputs to a factory you have to construct in your head. Inline removes the layer.

The same property reappears in three places because it is one property: the source must be the canonical representation. Anything that hides the actual feature shape behind a runtime construction breaks every tool that reads source.

The contract applies to what defines the feature’s shape. Registrar calls remain in feature.ts. Entity and screen definitions may be imported from the feature’s schema/ directory, translation keys from i18n.ts, and handlers from handlers/. The referenced values must still be regular, inspectable source rather than the result of runtime branching.

What does not have to be inline:

  • Handler bodies. The function that runs when incidents:resolve is called is a regular TypeScript function. It can live in handlers/resolve.write.ts, do whatever it needs to do, import what it needs to import. The patcher and the designer do not read handler bodies.
  • Helper functions used inside handlers. A slugify, a date calculation, a domain-specific validation, those are normal code.
  • Anything used at boot but not defining the feature shape. Schema helpers that return Zod schemas, constants used in multiple field definitions, enum-like as const arrays.

The line is whether the shape of the feature, what entities, what fields, what handlers exist, can be read by walking the source. As long as that property holds, everything else is unconstrained.

A bundled feature like audit or secrets is a function that returns a feature definition: createAuditFeature(opts). That is exactly the factory pattern app features may not use. Bundled features get a pass because they are not designer- or AI-editable. A user adding a field to their incidents entity is editing app code. Nobody adds a field to the audit feature through the designer.

The split keeps the contract honest. Bundled features need configuration options, which transport to use for email, which storage backend to use for files, and forcing them inline would mean every app re-declares those options. App features have no equivalent need: their shape is their definition.

Move the defineFeature call to feature.ts, keep the registrar calls visible, and move entity or screen constants into schema/. Runtime options that select providers belong at app composition or in a bundled-feature factory. Business rules belong in handlers or lib/. The feature still runs before this cleanup, but source-based tools cannot safely edit a graph they cannot resolve.

Inline authoring is the contract that makes everything else work. The schema-as-data property in entities, the boot-time validation of the feature graph, the designer’s ability to render a feature, the AI builder’s ability to edit one, every property compounds on top of the source being readable by tools.

The cost is some explicit registration and a fixed folder structure. In return, tools can read and edit the same source that reviewers approve.