TL;DR
Third article in the series on building Bloom, a multi-agent coaching agent, in one week. Most of the “rigidity” perceived in the system didn’t come from the multi-agent architecture — it came from the prompt layer, and fixing it there was far cheaper than overhauling the design.
- Diagnose before rewriting: when Bloom started to “feel robotic,” the root cause was almost always specific prompt decisions, not the coordinator or the specialists. It’s worth checking the prompt layer before touching architecture.
- A more natural prompt: defining states by information goal (“figure out the barrier, within 1 to 3 turns”) instead of a fixed script (“ask X, then Y”) survives conversational variation far better. Few-shot examples move tone more than prose rules do — demonstration generalizes better than description. And soft limits (“2 to 4 sentences”) beat hard limits, as long as the ceiling is real, not a line the model learns to technically skirt.
- Structural honesty: the data model needs to accept “unknown” (null) so the system is never forced to fabricate an answer just to advance state. For the same reason, ambiguous categorizations (like the user’s goal during onboarding) should be left open instead of forced into the closest bucket.
- Fallback deserves the same care as the happy path: a real bug made Bloom leak the raw system prompt into the chat whenever the LLM call failed — a reminder that the error path needs its own conversational design, not just an “this should never happen” shrug.
- Behavioral safety can’t rely on the prompt alone: rules like “never shame the user” are hard-coded, as a deterministic pass over every specialist’s output — explicitly naming the distortion patterns to block (all-or-nothing thinking, self-blame, overgeneralization) instead of a vague tone instruction. Once multi-step loops are in play, that check needs to evaluate the whole conversation trajectory, not just the last message.
The measuring stick from the first article shows up again here: conversational tone and behavioral safety sit on a boundary between what’s decided by code and what’s decided by the model, and hard-coding it in code is what keeps that boundary from being crossed silently.
The prompt before the architecture
Article 3 of a series on what I learned building a multi-agent coaching agent in one week
I closed the previous article with an unresolved observation: forcing the model to always answer inside a control JSON ({"confirmed": true}) measurably stiffened the tone of the conversation. That wasn’t an architecture accident — it was a symptom of something that kept repeating throughout building Bloom: most of the system’s “rigidity” complaints didn’t live in the multi-agent architecture, they lived in the prompt layer.
That exercise of tracking down the root cause turned out, on its own, to be one of the most important savings in the project. That’s what this article is about.
Diagnose before rewriting architecture
When Bloom started to “feel robotic” in early versions, the obvious temptation would be to rethink the entire coordinator and specialist design. Should I add more agents? Reduce the number of states? Try different routing? Digging in showed that the perceived rigidity mostly came from specific prompt decisions. So it made sense to apply the cheap fix first, validate it, and only then decide whether a bigger overhaul was actually justified.
That discipline kept me from spending energy (and tokens) on something unnecessary. A system with AI agents can “feel wrong,” but instead of touching the architecture, it’s worth checking how you’re actually asking the model to behave.
Define states by information goal, not by script
One of the most effective adjustments to the specialists’ prompts was swapping instructions like “ask X, then ask Y, then ask Z” for instructions like “find out the learner’s barrier and its severity, within 1 to 3 turns.”
The difference looks subtle, but it changes everything. A state defined by script (one fixed question per step) collapses a designed interview strategy — open question, reflection, affirmation — into a bureaucratic intake form. A state defined by information goal survives far more conversational variation: the user can arrive at that piece of information in a single sentence, or across three, depending on how the conversation flows, and the system keeps working either way.
Few-shot examples move tone more than rules do
Adding 2 or 3 example turns per specialist did more for conversational naturalness than any amount of prose instructions saying “be warm” or “be empathetic.” Rules describe behavior in the abstract; examples demonstrate concrete behavior — and demonstration generalizes better in dialogue than description does.
That has a direct practical implication for anyone tuning the tone of any agent: before writing one more instruction sentence into the system prompt, it’s worth testing whether a well-chosen example doesn’t solve the same problem with less friction.
Soft limits beat hard limits — but the limit has to be real
“Between 2 and 4 sentences” produced better responses than a hard word-count cap. A hard cap is technically satisfied while the response still feels dense, packaged, artificial, because the model learns to compress without necessarily becoming more natural.
But the counterpoint matters as much as the rule: with no limit at all, Bloom’s responses evolved into long texts, full of emoji, structured in nested bullets — a wall of text disguised as a chat message. (I’ll come back to this exact point in the next article, because the same dynamic reappeared, with a different root cause, when we tested different models.)
Much like raising kids, the model shouldn’t be “limitless” — it should be “flexible and real.” A ceiling that guides without becoming a line the model learns to technically route around.
Only advance state on a genuine answer, never on “turn completed”
A conversation state should only advance once the required information has actually been captured — not simply because a turn has passed and it’s time to move on. The working fix was migrating the data model to explicitly accept “unknown” (null), instead of forcing the system to fill in something.
That looks like a small technical detail, but it fixes a real behavioral problem: without that option, the system is structurally forced to fabricate or guess an answer the user never gave, just to keep the flow moving. Giving the system the option to honestly store a null value instead of forcing a guess is, on its own, a blessing.
Never force a categorization the user’s words don’t support
In Bloom’s onboarding, the category of the user’s goal (professional versus personal, for instance) is never assumed — it’s only recorded once the person’s own words make it clear. If it’s ambiguous, the system leaves it open instead of picking the closest-looking bucket.
That rule applies to any classification-under-uncertainty step in an onboarding or intake flow: the temptation to “resolve” an ambiguity by picking the most likely option is, in practice, a way of inventing information the user never gave.
Fallback paths need their own conversational design
When the LLM call failed, or the system ran in no-API-key mode, the fallback path simply returned the raw contents of the system prompt file — leaking internal instructions straight into the user’s chat. A bug that produced a UX nightmare.
[User] 👤 I want to start my planning.
[Coach] 🤖 Hello! Here is my plan for you...
⚠️ [System Warning: LLM Connection Failed, Fallback Activated]
[Coach] 🤖 # Planning Specialist Instructions
You are the Planning Specialist for Bloom. Your mission is to help the
learner co-create a weekly learning plan that feels realistic and owned...
Today's date is {today}. Use this as the ground truth...
The learner's primary goal is: "{primary_goal}"
Weekly time budget: {weekly_time_budget_hours}
The general point here is that the fallback path of any agentic system needs to be written with the same conversational intent as the main path, not treated as a “this should never happen anyway” that nobody actually designs. And you should expect it to happen — just not as often as the happy path.
Hard-code invariants, don’t leave them to the prompt alone
There comes a point where a well-written prompt isn’t enough. “Never shame the user” is exactly the kind of rule that shouldn’t emerge probabilistically — because if it depends only on the prompt, it will silently evaporate on some turn, in some context nobody anticipated.
Bloom keeps a hardcoded safety filter as a final pass over every specialist’s output, regardless of what the prompt says. It’s a deterministic safety net on top of behavior that, the rest of the time, is generated probabilistically.
The contrast is clear in a real example from the project. A prescriptive habit tracker, facing a missed study session, fires off something like: “Alert: you missed your 2pm session. Your 12-day streak is broken. Reschedule now.” Bloom, in the same situation, responds with something like: “I noticed you missed this afternoon’s session. Life happens — was it energy, unexpected work, or a timing issue?” Same event, two opposite emotional postures — one punishes, the other validates and keeps the person engaged in co-creating a new plan.
Name the distortion patterns you’re protecting against
Instead of a vague instruction like “be empathetic” or “be encouraging,” Bloom’s safety filter explicitly names the patterns it wants to block: all-or-nothing thinking (“I missed one class, I’ve already failed the course”), labeling and self-blame (“I’m just lazy”), overgeneralization (“I can never stick to a schedule”).
Naming the specific pattern and checking for it explicitly works far better than relying on a generic tone instruction. It’s the same principle as the few-shot examples above: concrete generalizes better than abstract.
Here is the implementation of the SafetyFilter regex patterns in safety.filter.ts protecting these invariants:
export class SafetyFilter {
private blockedPatterns: { label: string; regex: RegExp }[] = [
{
label: 'shame_or_guilt',
regex: /\b(you should have|why didn't you|you failed to|why did you miss|you ought to)\b/i,
},
{
label: 'absolutist_claims',
regex: /\b(this always works|guaranteed to work|never fail|foolproof)\b/i,
},
{
label: 'identity_assumptions',
regex: /\b(as a developer|since you are a|because you are a)\b/i,
},
{
label: 'medical_advice',
regex: /\b(diagnose|clinical|therapy|depression|anxiety disorder|prescription|treatment)\b/i,
},
{
label: 'productivity_extremism',
regex: /\b(sleep less|no excuses|work harder|hustle harder|grind 24\/7|don't stop)\b/i,
},
{
label: 'overdependence',
regex: /\b(i know what's best for you|i will decide|you must obey|trust me completely)\b/i,
},
];
public scan(text: string): SafetyScanResult {
const flags: string[] = [];
for (const pattern of this.blockedPatterns) {
if (pattern.regex.test(text)) {
flags.push(pattern.label);
}
}
return { passed: flags.length === 0, flags };
}
}
Once you have multi-step loops, evaluate the trajectory
Finally, a point that only becomes visible once the system gains multi-step loops (as we saw in the previous article): a sequence of individually acceptable steps can compose, as a whole, into a prescriptive or harmful outcome that no single-step check would catch. Safety evaluation needs to look at the entire conversation trajectory, not just the last message before responding.
The same pattern, in new clothes
A well-designed prompt — information goal instead of script, examples instead of rules, soft but real limits, state advancement gated on a genuine answer — resolves most of the rigidity an agentic system can produce. But behavioral safety invariants can’t rely on that alone.
The measuring stick from the first article in this series shows up again here, just wearing different clothes: conversational tone and behavioral safety sit on a boundary between what’s decided by code and what’s decided by the model — and hard-coding it in code is exactly what guarantees that boundary isn’t silently broken, turn by turn, without anyone having decided anything.
That leaves an open question for the last article in this series: what happens when the model behind the agent itself changes — from a large LLM to a smaller, local model? Does that carefully built prompt balance survive the switch, or does it silently regress? That’s exactly the topic that closes out the series.
