Code owns the facts
Subhankar Denria
CTO & product engineer
What this part does
Make sure every date, amount and heading in the kit is one the app worked out itself. The model is given the facts and asked only for words.
- The rule
- The model never chooses a date, an amount or a layout
- Costs to run
- Nothing — it's plain code, and it runs before the model is called
- What you get
- A plan that always fits the dates, and a page that always renders
A real moment: a three-week push needs a plan
Someone has to decide what gets sent, and on which day. It looks like an obvious thing to ask a model for.
The moment
An organisation is about to launch something that runs for three weeks. Someone needs to know what to send on which day, and nobody on a small team has time to work it out.
A typical first build
- The model is asked for a timeline, and writes one that reads well
- One of its reminders falls two days after the finish
- Nobody spots it until the day arrives
This build
- Code works the dates out from the real start and end
- The model gets the finished list and writes one message per step
- Move the dates, and a rewrite works the plan out afresh
How: a small plan builder owns every date, and the response schema only accepts the steps that plan contains.
1. The basis: facts go in, and are kept
Before the model is called, code assembles what we call the basis: the title, the target, the start date and the end date, read from the draft itself.
basis = {
title: draft.title,
target: draft.goal_amount,
start_date: draft.start_date,
end_date: draft.end_date,
}The prompt hands these over under the heading use these facts exactly. Then the basis is stored inside the kit.
Storing it is what pays off later. If the admin changes the target or the dates after the kit is written, the review page compares the stored basis with the live draft, says the copy no longer matches, and offers a rewrite.
Stored with the kit
- title
- Winter warmth
- target
- £4,000
- starts
- 3 Nov
- ends
- 24 Nov
The live draft
- title
- Winter warmth
- target
- £4,000
- starts
- 3 Nov
- ends
- 24 Nov
The copy matches the draft
Every figure in the kit is the figure on the draft.
The kit always knows which facts it was written from, so it can always tell when they've moved.
2. Dates come from rules
A plan is a list of dates, and dates are facts. So the model never chooses one. A small plan builder works the whole schedule out from the length of the run:
steps = [ "launch" on the start date ]
if length >= 21 days: add "first reminder" at start + 7 days
if length >= 28 days: add "mid-point update" at start + half the length
if length >= 14 days: add "final-week reminder" at end - 7 days
if length > 0 days: add "close" on the end date
add "thank-you" on the day after the end
sort by date; if two steps share a day, keep the firstEach threshold has a reason. A reminder a week in only makes sense if it won't collide with the final-week push, so it needs three weeks. A mid-point update needs four.
- Day 0Launch
- Day 7First supporter reminder
- Day 14Final-week reminder
- Day 21Close
- Day 22Supporter thank-you
Three weeks earns a first reminder and a final-week reminder.
A relevant calendar date is added too, but only if it falls strictly inside the run. The model then receives the finished list and writes one message per step. It decides what to say on each day. It never decides which day.
3. The schema is built per request
The response uses structured output, so it's guaranteed to match a JSON schema. The useful trick is that the schema isn't fixed. It's generated for each kit, and the plan's step keys become an enum:
plan_messages:
type: array
items:
key: one of [ the step keys from this plan ]
message: "One or two sentences: what to send or post at this step."A message for a step that doesn't exist isn't something the model can return. And every property in the schema maps to exactly one block on the review page, so the page never has to handle a missing field.
Steps the plan contains
These become the only values a message’s key may take.
What that guarantees
- Every message belongs to a real step
- A step that isn’t in the plan can’t be written for
- The page never has a message with nowhere to go
Every property has one home on the page
- storyPage tab
- giving_levelsPage tab
- emailSupporter Email tab
- socialSocial Content tab
- plan_messagesPlan tab
One limit worth knowing: the structured-output mode in use only accepts a minimum array length of 0 or 1, so "exactly four giving levels" can't be written into the schema. The prompt asks for four, and code makes it so. It also drops any level at or above the target, removes duplicates and sorts what's left.
4. The model never writes HTML
The page story comes back as four plain-text fields: an opening, why it matters, what your support does, and a closing. Code does the layout: a bold lead line, bold section headings, a "How you can help" list built from the giving levels, and a closing ask.
What the model returns
opening
why_it_matters
what_your_support_does
closing
Plain text. No markup at all.
What code lays out
Why it matters
How you can help
- • £10 could help with materials for a week
- • £25 could help fund a session
- • £50 could help keep the doors open for a day
Two reasons it's done this way.
The layout is always sound. Markup written by a model needs sanitising and can still break a page. Plain text placed by code can't.
It survives the editor. The rich-text editor and the HTML cleaner both strip heading tags, so the layout uses only paragraphs, bold and lists. The admin can open the draft, change a word and save it, and the page looks the same as it did before.
What this part bought
- A plan that always sits inside the real start and end dates, whatever they're changed to.
- A kit that knows when its facts have moved and says so.
- A review page with no missing fields to guard against.
- A page story that renders the same before and after an edit.
None of it costs a token. All of it happens in code, before or after the one model call.
Booking new builds now
