Prompt of the Week
Chain
Intent Classifier Build Guide: Adding Decision Gates to Character Agents
A step-by-step guided build for adding an input intent classifier—a deterministic decision layer that sits between user input and character response. Instead of embedding initiative in system prompts, this classifier tags what the user just said, then gates which behaviors the character is allowed to perform this turn. The output is production code in your repo, behind a feature flag, with seven checkpoints for human approval at each stage.
# Input Intent Classifier: a guided build What this is: a guided build for an input intent classifier, the decision layer that gives a character or agent real initiative. A system prompt gives tendencies, not decisions. So the choice moves out of the prompt: a small classifier model names what the user just said, deterministic code decides what the character is allowed to do this turn, and the reply model writes the words. This page builds your version of that layer, in your repo. How to use it: hand this file to a coding agent (Claude, Codex, Cursor, Kimi, any of them) and point it at your repository. The agent executes the steps in order. After every step it stops at a checkpoint and shows you its work. You approve, it continues. Nothing to install; the deliverable is code in your repo, behind a flag that starts off. Siblings: third system in the library after factor-driven emotion and dual-model consciousness. Independent of both. If your product runs either, they stay sibling columns: the tag is not an input to them, they are not inputs to the classifier. Claim boundary: an engineering layer. It does not read the user's mind, diagnose the user, or write the reply. It decides one thing: what the character is allowed to do this turn. Agent: three rules before you start. This is a reference, not a mandate. Step 1 can end with "this repo does not need it": that is a finished, correct deliverable. Everything payload-shaped here (tag names, catalog entries) is illustrative. You derive the real ones from this repo and this product's transcripts. Never ship an example list. Every failure path you build must resolve to the product exactly as it behaves today. The floor is the current product. If you cannot guarantee that at any step, stop and say so. ## Step 1 — Read the repo. Decide if it needs this. Inspect the repository and answer: - Where is generation orchestrated? (file and function) - Is initiative currently hoped-for in a system prompt (percentages, "sometimes open up", "occasionally tell a story")? Quote the lines. Step 6 will neutralize them behind the flag; note where they live. - What cross-turn state exists (mood, disclosure depth, story progress)? - Which of those systems read the current user message directly? Each one that does is a hole this gate closes. - Can the visible path afford one small, fast model call? Target 200 to 800 ms, hard ceiling about two seconds including one fallback attempt; the same numbers Step 3 enforces. This layer fits when the product is a character, companion, NPC, or persona agent that must feel continuous; when some behaviours should fire only on the right kind of user message; and when firing on the wrong turn (a command, a stop) is worse than a missed fire. It does not fit a task agent routing tool calls (use a plain router), or a product with no cross-turn state yet (a tone policy is cheaper and more honest). Then propose, in plain language, what this could do for this product: two or three concrete opportunities you found in the repo, not generic promises. Name the behaviour, the place it lives, and what the gate would change. For example: "your NPC makes promises in dialogue and forgets them; a story catalog could pay them off when the player invites it," or "your companion has a mood flag nothing reads; it could earn deeper shares behind the gate." The human picks what interests them before anything is built. Even a "not needed" verdict should name the nearest thing that would help this repo. CHECKPOINT 1 — show the human: - The hole this fills here (or the sentence "this repo does not need it," and why): - What this could do for THIS product: two or three repo-specific opportunities, plainly worded: - Which of those the human wants (wait for their pick): - Where the layer would sit (files): - The quoted system-prompt tendency lines Step 6 will neutralize: - Latency and cost verdict: Stop until approved. If the answer was "not needed," you are done — leave the nearest alternative that would help, and stop. ## Step 2 — Write the tag list. Yours, from your transcripts. Read fifty real user messages from this product. Name the moves you see, what the user did to the character, not what they felt. Stop at six to ten. One tag must be a catch-all that earns nothing. Shape to imitate (do not ship these names): ``` invited_feelings - asked how the character feels; an open door shared_something_personal - the user disclosed, not asked gave_command - a direct instruction, no warmth said_stop - brakes: stop, drop it, enough made_a_joke - banter, no stakes asked_about_past - a question about the character's life small_talk - everything that earns nothing (mandatory catch-all) ``` Rules: closed list. Exactly one tag covers every message. A message that contains several moves resolves by precedence, and brakes win: if any part of it is a stop or a command, that is the tag, unless this product documents a different order. Each tag gets one behavioural sentence in the classifier prompt; examples live in tests, not in the prompt. The tag names what was said, never who the user is: no diagnoses, no traits, no vulnerability scores, ever. No transcripts? Do not invent them, and do not derive tags from the example list above. Ask the human for a redacted sample of real messages. If none exists yet, derive a provisional list from the persona and system prompt, mark it provisional in code, and let shadow mode in Step 7 be its validation. Transcripts are user data: do not send logs to any model provider without the human's approval. CHECKPOINT 2 — show the human: - The proposed list, one line each, with two real (redacted) messages per tag: - Which tag is the catch-all: - The precedence order for mixed messages (brakes first): - Transcript-derived or provisional (if provisional: the shadow-mode validation plan): Stop until approved. ## Step 3 — Build the classifier call. The contract: - Input: the current user message plus the last few turns of raw dialogue (three is enough), formatted with explicit role prefixes (User: / Assistant:); small models misattribute commands and disclosures in unlabeled text. Truncate oversize messages from the middle, keeping the head and the tail; intent survives that cut. - Output: exactly one tag. No explanation, no JSON, no prose. Temperature 0, minimal reasoning. - Outcome type: the wrapper returns one of classified(tag), failed(mode), or not_run(reason). Failure is never disguised as a tag. - Parsing: normalize, then accept only an exact whole-token match against the closed list. If the model decorated its output, a substring scan may rescue it only when exactly one known tag appears; two hits, or anything else, is failed(parse_error), never a guess and never a new label. - Model: small and served fast. The origin ran a small open-weight model at 500+ tokens a second, because this call sits inside the user's wait, right before the reply, every turn. Keep a cheaper fallback model behind the primary. Never a frontier model. - Latency: target 200 to 800 ms; hard ceiling about two seconds including the single fallback attempt. On the ceiling: failed(timeout). Never retry into the user's wait. - Early skips: the first few turns of a conversation and empty messages skip the call entirely; the outcome is not_run(early_skip). - Blindness: the classifier may see words. It must never see state: no mood numbers, no phase, no pipeline verdicts, no prior tags. Feed it state and the leftover condition starts writing this turn's reply; the gate is gone. - Telemetry per turn: outcome, tag when classified, latency, model used, failure mode (parse_error / timeout / fallback_failed) as distinct records. One merged empty result hides production failures. CHECKPOINT 3 — show the human: - The classifier prompt (tag list + one line each): - The typed outcome in code (the union/enum): - The parse function and its tests (decorated output, two-tag output, garbage, empty): - Measured latency on ten real messages: Stop until approved. ## Step 4 — Build the gate. The gate consumes the typed outcome. Only classified(tag) enters the mapping: failed and not_run never reach it; they resolve in Step 6 to no injection. For each catalog, deterministic code maps the tag to exactly one of: - allow: existing state gates run unchanged; - skip: no injection this turn, whatever state says; - constrain: state gates run, then the resulting depth is clamped down to the tag's cap; a cap of zero is a skip. Constrain never raises depth. It may also pin the catalog entry to an intent-matched one (a personal question answered with a memory, a comforting message with a small crack) so the capped behaviour still fits the move that permitted it. An invitation or a personal question: usually allow. A command, a stop, a joke: skip. Placement: run the skips before the state chain, a cheap early exit, and apply the caps after it, because the depth being capped is computed by state. State still rules. State is the condition the character is in: still angry, still cautious, something waiting to be said. A small machine of named conditions in code. The gate's authority is one-directional: the tag can close a door state opened; it can never open a door state kept shut. It never touches facts, memory, tools, permissions, consent, safety policy, or entitlements. Safety systems run before and after this layer, unchanged, on every path. CHECKPOINT 4 — show the human: - The tag → skip/allow/constrain table, per catalog: - Proof (test) that constrain only clamps depth downward (cap zero = skip): - Proof (test) that a skip injects nothing regardless of state: - Proof (test) that failed / not_run outcomes never reach the mapping: Stop until approved. ## Step 5 — Write the catalogs. Directions, never lines. A catalog holds injections: compact behaviour directions the reply model performs in its own words. Not one scripted sentence, ever; the reply model owns every word the user reads. A good entry names its beats and caps them: "Two beats. Beat 1: the truth slips out. One sentence, the character didn't mean to say it that openly. Beat 2: a shorter sentence covers it. Reflexive embarrassment, never analysis. Never a third beat." Give depth levels (a small crack → a memory → a longer admission) and put cooldowns on the deep ones: never twice in a row, the deepest waits several turns. A character that floods every turn is exactly as fake as one that never does. If the gate allows but no entry is available (everything on cooldown, nothing at the permitted depth, an empty catalog), the turn proceeds with no injection and logs no_available_entry. That is a normal path, not an error. Write the catalogs for this product: what would this character do with permission? Two catalogs is a good start: one for opening up, one for volunteering or inviting a story. CHECKPOINT 5 — show the human: - Each catalog: entries, depth levels, cooldowns: - Confirmation that no entry contains quoted character dialogue: - The no-available-entry path and its log line: Stop until approved. ## Step 6 — Wire it into the turn. Behind a feature flag, default off. The failure contract in one sentence: anything other than a clean classified(tag) means this layer injects nothing. Keep the two quiet states distinct in code and telemetry: - Flag off / wrapper never ran (not_run): the catalogs' intent gate is never consulted and nothing injects; byte-for-byte the pre-classifier product. Rollback is the flag. - The call failed (failed(timeout | parse_error | fallback_failed)): no injection this turn, logged with its mode. Both resolve to the product you already have, because the catalogs are new in this build: before it, nothing injected; on failure, nothing injects. Classifier and catalogs fail the same direction: closed, to silence. The reply is never blocked. Exception (catalogs that pre-existed the classifier): if this product already had injection systems running before you added the classifier, "the product you already have" includes their behaviour. A failure must then let those old gates run unchanged, not silence them. Split the flags (catalog flag separate from classifier flag), pick your failure semantics deliberately, and defend the choice at Checkpoint 6. One more wiring duty: Step 1 quoted the system-prompt lines that hoped for initiative (percentages, "sometimes open up"). When the flag is on, neutralize those lines (gate them behind the flag), or you ship two competing decision layers, the old tendencies still firing underneath the new gate. When the flag is off, the prompt stays byte-for-byte the original. CHECKPOINT 6 — show the human: - A run demonstrating both quiet states (flag off, and a forced classifier failure) producing today's product (show the assembled-prompt diff: empty): - One assembled reply prompt with an injection present, and one without: - The prompt-scrub diff: tendency lines neutralized when the flag is on, untouched when off: - If the pre-existing-catalogs exception applies: which semantics you chose, and why: Stop until approved. ## Step 7 — Prove it before the flag flips. Two passes, in order: Shadow mode. Flag still off for injection; classifier runs and logs. Read a hundred logged tags against their messages, per tag, not in aggregate. The number that gates progress is false allows on the brake tags: a classifier that agrees 90% overall but waves through one "stop" is not ready. A missed invitation costs a moment; a missed brake costs trust. Aim for eight or nine out of ten per tag, zero tolerated false allows on brakes. Then stop tuning; failure already resolves to silence. Paired off vs on. Freeze a set of scenes that covers every tag, including the awkward ones: a command right after an earned share; a joke while something personal is waiting. Run the set with the flag off, then the same set on. Same scorer both sides: a person, or a second model; never the model that wrote the replies. CHECKPOINT 7 — show the human: - Per-tag shadow agreement, brakes first: - Scenes (same both sides): - Off: - On: - Scorer (who, and why not the author): - What this does not prove: - Verdict: ship / no-ship / inconclusive / cannot tell Inconclusive and cannot-tell are legal verdicts. Shipping on one anyway (flag and quiet states intact) is the human's call to make, on the record. ## Do not Do not ship the example tag names or catalog entries from this page. Do not disguise a failure as a tag; the outcome type exists so that cannot happen. Do not let the classifier see state, or a catalog read the message's meaning. Do not put scripted dialogue in a catalog. Do not let the tag unlock anything state refused. Do not spend a frontier model on the tag, or let the call block the reply past its ceiling. Do not leave the old tendency lines live under the new gate. Do not flip the flag without Checkpoint 7. Seven steps, seven checkpoints, and at every one of them the worst case is the product you already have. Start tonight with Step 1. Its "no" is as useful as its "yes".