Object-Oriented Prompt Engineering for JavaEst. 2026 · MIT License
PrOOPt

“A Java library that keeps LLMs on a need-to-know basis.”

Chapter Four

PrOOPt States — Getting Started

In which objects acquire inner lives, and the inner lives acquire a database schema.

What Is PrOOPt States?

PrOOPt core governs what an LLM may do. PrOOPt States governs what an LLM may remember and become.

Here is the idea in one breath. You write a plain Java object. You describe what it is in a sentence on a @Persona annotation. You describe each of its fields in a sentence on a @Trait annotation. From then on, an LLM manages how those fields change — in response to events you send, on schedules you set, and even during quiet hours when the entity sits alone and reflects. Every change is persisted to ordinary database tables, every change has a recorded source, and the whole population is queryable with SQL-style predicates.

The developer describes what the entity is. The LLM decides what it becomes. The database remembers everything either of them did.

The Dependency

<dependency>
    <groupId>io.github.argonizer</groupId>
    <artifactId>prooopt-states</artifactId>
    <version>${prooopt.version}</version>
</dependency>

<!-- Or, for Spring Boot users who enjoy nice things: -->
<dependency>
    <groupId>io.github.argonizer</groupId>
    <artifactId>prooopt-states-spring-boot-starter</artifactId>
    <version>${prooopt.version}</version>
</dependency>

The starter wires the persona engine, the schedulers for evolution and internal loops, and the event pipeline into your Spring context. Personas persist via JPA by default (@Persona(store = Store.JPA)), and all personas share the same five physical tables — including prooopt_persona_history, which will quietly become your favourite table.


Your First Persona

Let us hire a castle guard. He is not a chatbot. He is state.

@Persona(
    value = "A city guard in a medieval fortress town. Dutiful but "
          + "underpaid, loyal to his captain, suspicious of strangers, "
          + "and fond of gossip on the night shift.",
    evolutionSchedule = "@daily",
    evolutionDescription = "Fatigue accumulates on long shifts and "
          + "recovers on rest days. Old grievances fade slowly unless "
          + "refreshed. Morale drifts toward the company's mood."
)
public class GuardNpc {

    @PersonaId
    private String guardId;

    @Trait("Morale from 0 (deserting at dawn) to 100 (would die "
         + "for the captain). Rises with pay and victories; falls "
         + "with hunger, fear, and unpaid wages.")
    private int morale;

    @Trait("Suspicion toward the player, 0–100. Rises sharply when "
         + "crimes are witnessed; rises slowly on rumours; decays "
         + "only with time and good deeds.")
    private int suspicion;

    @Trait(value = "Private running memory of recent encounters, "
         + "in the guard's own words.", index = false)
    private String memory;
}

Read those @Trait descriptions again. Each one is a miniature law of physics for that field. “Rises sharply when crimes are witnessed; decays only with time” is not a comment — it is the contract the LLM is held to every time it updates the field. Rich, specific descriptions produce better state transitions. Vague ones produce a guard with the emotional consistency of a weather vane.

Bring Him to Life

The PersonaStateEngine has a small, honest lifecycle: initialise, update, evolve, run a loop, retire, restore, load.

GuardNpc guard = new GuardNpc();
engine.initialise(guard, meta, "guard-042",
        "A veteran of the northern gate, ten years of service");

// Something happens in the world:
PersonaStateDiff diff = engine.update(guard, meta,
        "The player was seen picking the lock on the granary door "
      + "at midnight.");

System.out.println(guard.getSuspicion()); // noticeably higher
System.out.println(diff);                 // exactly what changed, and why

The engine builds a prompt from the persona description, the trait contracts and the event; the LLM proposes new values; the engine parses, validates, writes the fields, and records the change in prooopt_persona_history with its source. You get back a PersonaStateDiff — the before, the after, and the reasoning. No mystery, no vibes. Rows.

What “Persisted and Queryable” Buys You

Because every trait is indexed by default, a population of personas can be queried like inventory:

List<GuardNpc> disgruntled =
        guards.loadWhere("morale < 20 AND suspicion > 70");

That single line is the difference between PrOOPt States and a pile of chat transcripts. State lives in tables. History has source attribution — was this change caused by an external event, social contagion, scheduled evolution, or the entity's own internal reflection? (UpdateSource knows.) And large text blobs like narrative memory can opt out of indexing with index = false, because nobody needs a B-tree over a guard's diary.

Where to Next?

You have one guard with feelings and a history table. The States Tutorials scale this up: generating whole populations with census-shaped distributions, broadcasting events with blast radii and rumour distortion, scheduled evolution, internal thought loops, deterministic veto power, and the longitudinal reports that make it all legible.