Skip to content
← Back to writing

AI · SEP 2026 · 9 MIN READ

Personalization is not a prompt

Personalization starts before the model call. Learner state, product rules, and code constrained every story I generated for Wondika.

Personalization is not a prompt

I built Wondika around a simple promise: a child could choose a hero and a world, and the app would generate a math adventure for them.

The obvious implementation was a single prompt. Pass the model the child's age, character, setting, and math topic, request a story, and parse the output.

That approach produces personalized prose. It does not produce a personalized learning product.

This distinction shaped the whole system. Before Wondika called a language model, the application had already chosen what the child should practise, the difficulty level, the required number of challenges, every correct answer, and the plausible mistakes. The model received a creative task within a boundary defined by code.

That boundary was the personalization system.

Four decisions before generation

Personalization in Wondika covered four distinct categories of state.

The first was learning state. Each child had a knowledge grade and a mastery estimate for individual skills. The learning engine selected a skill from the child's current frontier, respected prerequisites, brought mastered skills back for review, and marked a child as struggling after repeated adventures on the same material.

The second was difficulty. A child starting a new skill received easier challenges. Higher mastery raised the difficulty, while a struggling state lowered it. Grade level controlled both challenge count and text volume per scene.

The third was creative choice. The child selected a character and a world. Those choices shaped voice, visual identity, setting, and narrative style. They never altered the correct answer.

The fourth was presentation. Locale, reading level, narration style, and story length changed what the child saw and heard without altering the learning objective underneath.

None of those concerns belongs in one undifferentiated user profile. Each has a different owner, update rule, and failure mode. Keeping them separate meant I could inspect why a story took the shape it did instead of treating the prompt as an explanation.

Wondika's personalization pipeline: learner state and child choices become deterministic constraints before generation, then validators and media delivery produce the experience and answers update mastery.

The scene plan was already a product decision

The learning engine did not rely on the LLM to structure lessons. It selected a skill, a scenario template, and a mastery value, which a scene-plan builder converted into a fixed narrative rhythm.

The production code mapped mastery to difficulty, lowered that difficulty when the child was struggling, and built a different story length for each grade band:

function mapMasteryToDifficulty(pMastery: number) {
  if (pMastery < 0.3) return "easy"
  if (pMastery > 0.7) return "hard"
  return "medium"
}

const difficulty = struggling
  ? downshiftDifficulty(mapMasteryToDifficulty(pMastery))
  : mapMasteryToDifficulty(pMastery)

const plan = [
  { slot: "cold_open" },
  { slot: "narrative", purpose: "setup" },
  { slot: "challenge", position: "opening", difficulty },
  { slot: "narrative", purpose: "reaction" },
  // more beats based on the child's grade
  { slot: "closing" },
]

For the youngest grade band, the complete plan contained four challenges. The middle band received six. The oldest received seven. Narrative beats between them had explicit purposes such as setup, reaction, bridge, midpoint, setback, and regroup.

The structure controlled how many challenges arrived together and what narrative separated them. Asking a model to "make it engaging" leaves it free to put three questions together, skip the setup, or resolve the plot before the hardest challenge. A typed plan made those failures testable. If a generated episode returned the wrong number of scenes, mismatched a scene type, or placed more than two challenges together, it was rejected.

The model retained room for narrative variation. It could not rewrite the lesson plan while using it.

The LLM never owned the answer

The most important boundary was also the simplest: the language model never performed the arithmetic.

Challenges existed independently of the story. Each stored its variables, correct answer, plausible mistake types, options, format, and difficulty. Application code validated the arithmetic, evaluated the child's answer, and updated mastery. The generated story framed that challenge in a situation where the answer mattered.

Application code ownedThe model owned
Skill selection and prerequisitesPlot and prose
Difficulty and scene countCharacter voice
Challenge variablesStory situation
Correct answerEmotional reaction
Plausible mistake typesVisual direction
Mastery updateTitle and reward language

This division kept correctness out of the model's probability distribution. It also made a wrong answer useful. A selected mistake type could trigger a consequence written for that misconception, while the main plot continued. Wondika did not generate an exponentially branching story tree. It generated bounded local consequences around a canonical arc.

That was a deliberate product tradeoff. Full branching sounds more personal, but it multiplies continuity problems, media cost, and generation latency. Local consequences gave an incorrect answer narrative weight without making later scenes depend on every earlier branch.

A prompt contract, not a request

Once the application had assembled the plan, the model generated the complete episode as structured output. The system prompt set the reading style and locale. The user prompt supplied the character, world, scenario, scene plan, allowed variables, and challenge rules. A schema constrained the output shape.

The hard part was preventing output that was structurally valid and educationally broken.

A challenge scene had to end with an in-character question. It had to name the concrete thing the child was solving for, not ask for "the answer". It had to include the input variables needed to reconstruct the problem. It could not reveal the correct result, any wrong option, or a target quantity equal to the answer. Ordering challenges had separate rules because listing the items in the prose would reveal the sequence already visible in the UI.

Image prompts had a different contract. Challenge numbers were forbidden so an illustration could be reused with different values. Character identity came from a reference image or LoRA when available. The only identity placeholder allowed in an image prompt was {{character_name}}; foreign placeholders were stripped or repaired before hydration.

These were invariants enforced after generation. The pipeline validated the schema, scene count, scene order, challenge variables, character token, placeholder set, and answer-concealment conditions. A model response that missed one of them entered retry and fallback logic rather than becoming a child's next screen.

Reuse was part of personalization

Generating everything on demand was the slowest and most expensive route.

Wondika's orchestrator checked three paths in order:

  1. Reuse a completed adventure suitable for the free tier.
  2. Reuse an unseen story template matching the skill, world, scenario, grade, and locale.
  3. Generate a new episode when neither pool had a match.

The second path is the interesting one. A reusable template kept the plot and scene rhythm but replaced the character and challenge state. Narrative text stored placeholders. Challenge scenes were cleared and hydrated with the new child's selected problems. Images and speech could be resolved again where identity or locale required it.

Personalization did not require every byte to be novel. It required the parts that represented this child to be correct. Reusing a reviewed story skeleton avoided paying a model to rediscover the same structure.

The cost was a second content lifecycle. Generated stories could no longer be treated as disposable responses. Templates needed match keys, locale variants, seen-history tracking, promotion rules, review scores, and refill jobs. Reuse saved generation work by creating catalog work.

The first scene did not wait for the last

A personalized story combined several slow systems: an LLM, image selection or generation, storage, and text-to-speech. Optimizing one provider would not remove the wait created by the rest.

I treated delivery as a pipeline. While the model streamed the episode's structured result, a brace-depth tracker detected when scene zero was complete. That one scene was parsed, validated, and dispatched to image and speech hydration before the remaining scenes had arrived. The API persisted a skeleton for the full plan and made the opening available while later scenes continued in the background.

There were failure paths inside that optimization. The scene parsed early might differ from the final validated output. Hydration might finish before persistence, or fail after another path had already started. The implementation compared the early and final narrative, image prompt, and selected image key before accepting the fast path. A mismatch triggered clean rehydration.

This is where an AI feature becomes ordinary distributed systems work. Streaming helps only when partial output has a stable boundary, can be validated independently, and cannot race the final write.

Evaluation sat outside generation

Validation confirmed whether an episode obeyed the contract. It did not measure whether the story was good.

A separate evaluation job scored completed adventures across narrative coherence, age appropriateness, engagement, pedagogical correctness, and image relevance. The evaluator received the target skill, scope, common mistakes, guidelines, scenes, challenges, options, and correct answers. Its result was stored with the model version and cost.

The evaluator was still a model, so I did not treat its score as truth. It was a filter and an observability signal. Arithmetic checks remained deterministic. Operational logs recorded model usage and cost. Retry policies could change the provider. Media failures could degrade without erasing the narrative. A failed generation could release the quota reserved at the start.

The layers were intentionally unequal. Code enforced what code could know. Models judged the qualities that resisted exact rules. Humans retained the final say over prepared content.

What this architecture cost

The simple prompt disappeared under an orchestration system: learning state, scenario catalogs, typed plans, template filling, model configuration, image pools, speech providers, validation, evaluation, retries, fallbacks, background jobs, and per-adventure cost tracking.

Some constraints also reduced creative freedom. Number-agnostic image prompts produced more reusable illustrations, but could not depict the exact objects in a problem. A canonical story arc avoided a branching explosion, but the child's wrong answer changed a consequence rather than the rest of the plot. Reusable templates improved delivery, but introduced repetition that needed seen-history and rotation.

I would make those trades again for a child-facing product. I would not make all of them for a disposable story generator. The value of the architecture depends on what being wrong costs and what the next screen promises the user.

Wondika shipped on iOS and was later shut down. The case study covers the full product and delivery stack. The part I cannot claim is that this architecture improved learning outcomes. It made each adventure inspectable, testable, and personal in ways one prompt could not. Proving that children learned more would have required a longer-running product and evidence the generation system itself could never provide.

Hau Vo

Hau Vo

Senior product engineer and software architect.

Building something this touches on

I take ideas to production software. A 30-minute call tells you whether I'm the right person.

Book a call