Skip to content

Inline authoring

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.

// Inline — what app features must look like
export default defineFeature("incidents", (r) => {
r.entity("incident", {
fields: {
title: createTextField({ required: true }),
severity: createSelectField({ options: ["low", "high"] as const }),
},
});
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 is editable by tools. The second is opaque to them.

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, walks to the r.entity("incident", { fields: … }) node, inserts the new field, and writes the file back. The patcher finds entities by literal path: it expects to see r.entity with a string-literal name and an object-literal options argument inside the defineFeature body. A factory call with arguments computed at runtime gives the patcher no fixed point — it cannot statically know the options object exists, much less where to insert.

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. Entities, their fields, the access blocks, the relations, the events, the projections — all of these are literal expressions in the defineFeature body. Field factories like createTextField({ required: true }) are allowed, because the input to the factory is itself an object literal with statically inspectable arguments. The patcher reads through one layer of factory call as long as the call site is regular.

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.

Existing apps that were written before the inline contract took shape sometimes have factory-style features. PublicStatus is the prominent example in the Kumiko monorepo. The cost of migrating is real but bounded: each factory becomes a regular defineFeature call, the options that were arguments become inline literals, and the AST patcher gains the ability to edit them. Migration is gated on the patcher landing — until then, factory-style features still run, they just are not designer- or AI-editable.

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 small: feature definitions are slightly more verbose, because options that would be arguments to a factory become literal fields in an object. The benefit is that everything that needs to read your code, can.