All notes
AI EngineeringPart 2 of 3October 9, 20268 min read

Code owns the facts

Subhankar Denria

CTO & product engineer

~5 min

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.

A three-week push needs a plan

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.

pseudocode
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.

The kit remembers what it was written from

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:

pseudocode
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 first

Each 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.

The same rules, three different lengths
  1. Day 0Launch
  2. Day 7First supporter reminder
  3. Day 14Final-week reminder
  4. Day 21Close
  5. Day 22Supporter thank-you

Three weeks earns a first reminder and a final-week reminder.

The model receives this list and writes one message per step. It decides what to say on each day, never which day.

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:

pseudocode
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.

A schema written for this kit only

Steps the plan contains

launchfirst_reminderfinal_weekclosethank_you

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
Shown for a three-week run. A six-week run gets a different list, and so a different schema.

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.

Four plain fields in, a laid-out page out

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
Only paragraphs, bold and lists, so the layout is still intact after the admin opens the draft and saves it.

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.