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.
Declare the slots
Section titled “Declare the slots”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.
Write the fill decision
Section titled “Write the fill decision”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.
Give it sample data
Section titled “Give it sample data”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.
Render them
Section titled “Render them”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.
See it
Section titled “See it”Save, and ask the running dev server what the section now has:
curl 'http://localhost:5180/api/inspect?template=starter§ion=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.
Check it
Section titled “Check it”npm run check> check> template-kit check
✓ startercheck passedTwo 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.
The author loop
Section titled “The author loop”That is the whole cycle, and there is nothing more to it than three steps:
- Edit a section’s files —
section.ts,fixtures.ts,Renderer.tsx. - Preview —
npm run devand look, or/api/inspectand read, whichever suits the change and the terminal you are in. - Check —
npm 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.
Verify
Section titled “Verify”npm run checkprints✓ starterandcheck passed./api/inspectreportsblurbfilled with the sentence you put infixtures.ts, andaddressfilled with a real street address,"provenance": { "kind": "fact" }, from the scenario rather than from anything you wrote.- The section renders three elements, not one —
outlineshows 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.