Skip to content
HomePagesHomePages template kit

Add a slot

Add two slots to the starter section — one the platform resolves from the property's own data, one the AI writes — and take both from declaration to a green check.

main has one slot. Give it two more — an address, which the platform resolves from the property’s own data, and a blurb, which the AI writes — and take both from declaration through render to a green check. They are declared the same way, one line each, and filled by entirely different machinery; that difference is the thing to take away from this page.

Leave npm run dev running from First render. Every edit below reloads in it as you save.

Open templates/starter/sections/main/section.ts. Two lines join slots, and fact joins the import above it:

address: text.short({ source: fact.property.address, writeback: false, label: "Address" }),
blurb: text.long({ label: "Blurb", cap: 240 }),

(The rest of the file — the SectionProps import, headline, Props — is untouched for now.)

The two new lines differ in exactly one option, and it decides everything that follows.

source: fact.property.address, writeback: false binds the slot to the property itself. fact.* is the kit’s catalog of fields every property has, and source says this slot’s value is that field — resolved from the deliverable’s own data before any model runs. You are not asking for the address to be written; you are pointing at where it already is. See Facts and provenance.

text.long({ label: "Blurb", cap: 240 }) names no source, so nothing can resolve it. That is what leaves it for AI fill — a long-form paragraph about this property, which no field of the property contains. cap is the slot’s character budget, declared here and nowhere else: the decision below inherits it. See AI fill.

Every slot can arrive empty: the section still renders when the property has no address on file and the fill pass produces no blurb.

blurb is written, so it needs a decision — declared on the slot itself, as its fill:. Give the blurb line above a third option:

{
structure: "paragraph",
perspective: "third",
voice: "Warm, plain, and specific to this property.",
content: "Two or three sentences on what makes this property worth a visit, under 240 characters.",
}

…so that the slot reads blurb: text.long({ label: "Blurb", cap: 240, fill: textBlock({ … }) }). There is no second declaration to keep in step with the first: the decision’s id and produces are the slot’s own key, so they cannot drift from it.

address gets no decision, and that is not an omission. It is not written, it is read: a slot with a source is resolved from the property’s data before the fill pass begins, so there is nothing for a decision to decide. The types know this — a slot declares exactly one of source: and fill:, so writing both on address is a compile error rather than something you have to remember not to do.

One value on that decision is worth reading twice. structure is one of single_sentence, paragraph or bullet_list — a closed union (TextStructure), so a fourth spelling is a compile error in your editor rather than something check finds later. perspective is the same shape, and required: every prose fill states the grammatical person its copy is written in. The rest of the field is on the fill-spec reference.

What check still catches, because no type can, is a section module that will not load at all: it takes the template composing it down with it, so section instance "main-1" references unknown section "main" beside a section.ts failure is a cascade, not a second bug. Fix the section and both go away.

fixtures.ts supplies what a preview renders. blurb needs a value there — and address deliberately does not get one:

export const fixtures = defineFixtures(
schema,
(scenario) => ({
base: scenario("luxuryMultiUnit", {
headline: "A typical headline that reads well in a short line",
blurb:
"Light fills every room from morning to dusk. The kitchen opens onto a walled garden, and the station is a six-minute walk away.",
}),
}),
);

A fixture authors only the slots nothing else can resolve. headline and blurb are written, so a preview has to be handed something to stand in for what the AI would produce. address is read from luxuryMultiUnit — the golden scenario this fixture already names — so writing a value for it would be inventing data the scenario already has, and inventing it in a way real content never would.

Declaring a slot does not put it on the page: a value reaches the markup only where a Slot.* primitive renders it. Two more lines in Renderer.tsx:

export default function MainRenderer({ slots, nav }: Props): ReactNode {
const slot = bindSlots(schema, slots);
return (
<Section id={nav.selfAnchor} className="bg-background text-ink">
<div className="mx-auto max-w-3xl px-6 py-12">
<Slot.Text slot={slot.headline} as="h2" className="text-2xl font-semibold" />
<Slot.Text slot={slot.address} as="p" className="mt-2 text-sm text-accent" />
<Slot.Text slot={slot.blurb} as="p" className="mt-4 text-base" />
</div>
</Section>
);
}

An unfilled text slot arrives as the blank "", and the renderer has to survive it. Slot.Text handles that for you: by default it still renders the element you named, marked and empty, so the editor has something to select when a user wants to type into it. Branch on slot.<name>.present when you would rather the element disappear entirely. The full set is on Slot primitives.

Save, and ask the running dev server what the section now has:

Terminal window
curl 'http://localhost:5180/api/inspect?template=starter&section=main&fixture=typical&format=text'
{
"target": {
"template": "starter",
"section": "main",
"fixture": "typical",
"schemaFile": "sections/main/section.ts"
},
"slots": [
{
"id": "headline",
"type": "text",
"fillState": "filled",
"rendered": true,
"value": "A typical headline that reads well in a short line",
"provenance": { "kind": "authored" },
"at": "section.ts:11"
},
{
"id": "address",
"type": "text",
"fillState": "filled",
"rendered": true,
"value": "101 Queensway, Jamaica Plain, MA 02130",
"provenance": { "kind": "fact", "ref": "address" },
"at": "section.ts:12"
},
{
"id": "blurb",
"type": "text",
"fillState": "filled",
"rendered": true,
"value": "Light fills every room from morning to dusk. The kitchen opens onto a walled gar…",
"provenance": { "kind": "authored" },
"at": "section.ts:13"
}
],
"variant": "",
"nullCorpus": ["null_headline", "null_address", "null_blurb"],
"outline": "…\n p.mt-2.text-sm.text-accent [slot:address][leaf] \"101 Queensway, Jamaica Plain, MA 02130\"\n p.mt-4.text-base [slot:blurb][leaf] \"Light fills every room from morning to dusk. The k…\""
}

address came back as a real street address, and you never wrote one. That is the whole point of the source you added: one option on one line moved the slot onto a different axis — not who writes this, but where does this already exist — and the platform resolved it from the property behind the scenario. The field that proves it is provenance. headline and blurb report "kind": "authored", meaning the value came out of fixtures.ts; address reports "kind": "fact" with "ref": "address", naming the property field it was read from. Delete the blurb line from fixtures.ts and its value disappears. Delete nothing, and address keeps arriving, because there was never a fixture holding it up.

Two smaller things changed in that response, and both came for free.

nullCorpus went from ["null_headline"] to ["null_headline", "null_address", "null_blurb"]. Those are empty-state fixtures the kit derives from the schema — one per declared slot — so the checks can see your section render with the value missing without you authoring a thing.

at gives the section.ts line each slot is declared on, which is what makes a surprise here navigable: if a value is not what you expected, the payload tells you which line to go read.

Terminal window
npm run check
> check
> template-kit check
✓ starter
check passed

Two lines, and no stage list — check runs six stages in one pass (typecheck, lint, validate, tree, render, size) and reports per file, not per stage. The last three are the ones a compiler cannot stand in for: tree is where a slot you declared but never rendered comes back as a missing-slot-marker problem — the mistake the previous section exists to prevent — render renders every fixture twice and scans the HTML for objective defects, and size holds each section’s bundles to a fixed byte budget. When something is wrong it says ✗ starter — N problem(s) and prints each problem with the file it is in, a fix: line and a link to the rule. check has the full output contract; Run check covers reading a red one.

That is the whole cycle, and there is nothing more to it than three steps:

  1. Edit a section’s files — section.ts, fixtures.ts, Renderer.tsx.
  2. Previewnpm run dev and look, or /api/inspect and read, whichever suits the change and the terminal you are in.
  3. Checknpm run check, and go back to step 1 until it is green.

Every guide in this corpus ends by running it: a guide shows you the declaration to write, and this loop is how you find out whether writing it worked.

  • npm run check prints ✓ starter and check passed.
  • /api/inspect reports blurb filled with the sentence you put in fixtures.ts, and address filled with a real street address, "provenance": { "kind": "fact" }, from the scenario rather than from anything you wrote.
  • The section renders three elements, not one — outline shows a [slot:address] and a [slot:blurb] under the headline.

check verifies that your declarations are internally consistent and that the code compiles. It does not tell you a slot came back empty in every fixture — that is a question only the preview above answers today, which is why the loop has a preview step in it and not just a check step.

Ship it — pack the template into a submission, and what the platform does with it.