I was building an onboarding flow: a new user lands, a chat window asks them three things - what problem they're solving, what their idea is, who the user is - and once all three are answered, the app needs { problem, idea, targetUser } as a clean, validated object. Not a transcript. Not "probably in there somewhere." An actual object my code could pass to the next step without re-reading prose.
My first pass was the obvious one: after every user reply, try to extract the object, check if it validated, and if it didn't, ask the model for a follow-up question. It worked right up until the extraction step threw partway through a conversation - a SchemaViolationError with actual validation details in it - and I hadn't wrapped that path carefully enough. The raw error text got forwarded straight into the chat window as if it were the bot's next question. A user saw "idea": expected string, received undefined where a friendly follow-up question should have been.
I fixed that specific leak with a try/catch. Then found a second one a day later in a different code path. That's when I stopped patching leaks one at a time and looked at whether the tool I was already using (shapecraft) had a mode built for exactly this shape of problem, instead of me re-deriving "never let internal errors reach the user" by hand, one bug at a time.
The actual design problem
It's not really "extract after every message and retry." It's two completely different concerns that I'd tangled into one loop:
-
Having a conversation - a model asking natural questions, probing vague answers, deciding when it has enough. That's chat. It should never be schema-constrained, because forcing JSON-shaped output mid-conversation is exactly how you get a bot that "helpfully" outputs a stray
{...}into the chat. - Producing a validated object, once, at the end - a completely separate concern from the conversation itself.
Conflating them means every turn is a place validation could fail and leak. Separating them means there's exactly one place validation happens - after the conversation is over - and exactly one rule to enforce: nothing generated internally at that step is ever routed back into the chat.
turnaround mode
generate(..., { turnaround: true }) is built around that split. The model drives the conversation entirely through systemPrompt - what to ask, when to probe a vague answer, when it's satisfied. Shapecraft's only job during collection is to relay the model's reply and carry the transcript forward:
import { generate } from "@aviasole/shapecraft";
const FACILITATOR =
"Ask exactly one focused question at a time to learn: (1) what problem are we solving, " +
"(2) what the idea is, (3) who the user is. Probe vague answers for specifics. " +
"When - and only when - you have clear answers to all three, reply with exactly <<<COMPLETE>>> and nothing else.";
let r = await generate(model, OnboardingSchema, "Hi", { systemPrompt: FACILITATOR }, { turnaround: true });
console.log(r.message); // "What problem are you trying to solve?" - the model's own words, forwarded verbatim
r = await generate(model, OnboardingSchema, "Onboarding takes too long", { systemPrompt: FACILITATOR }, {
turnaround: true,
memory: r.memory, // thread the running transcript back in
});
console.log(r.message); // "What's your idea to fix that?"
// ...more turns, same pattern...
if (r.status === "complete") {
console.log(r.data); // { problem, idea, targetUser } - validated once, here, never mid-conversation
}
memory is a plain JSON-serializable object - just the transcript plus a turn count - so a stateless HTTP handler can persist it between requests and rehydrate it on the next one, which is the actual shape a real chat backend needs anyway.
The completion sentinel
The model signals "I'm done" by replying with a literal <<<COMPLETE>>> instead of another question. That's the trigger for a single, separate generate() call - a normal structured extraction pass over the whole transcript, not part of the conversation the user sees. I liked this over having the model just emit JSON directly once it thinks it's done, for the same reason my original leak happened: raw or premature JSON has no business anywhere near the user-facing chat, and "is this the sentinel string" is a trivial check instead of a fragile "does this look like JSON" guess.
The rule that actually fixes my bug
Every response from a turnaround call is one of two shapes: { status: "collecting", message } where message is only ever the model's own conversational text, or { status: "complete", data } where data is only ever produced by the end-of-conversation extraction pass. There is no third path where something shapecraft generated internally ends up in message. If that end-of-conversation extraction fails validation, it throws a real, terminal error out of generate() - it does not get wrapped into a fake follow-up question, and the user is never re-interrogated for answers already sitting in the transcript.
That last part surprised me at first - I expected "validation failed, so ask again." But if the user's answers are already in the transcript, a validation failure at the end means my extraction step is broken, not that the user needs to repeat themselves. Re-asking would be asking the wrong party to fix the wrong problem.
Where that leaves it
My onboarding bot now has exactly one place structured data gets produced, and exactly zero places an internal error can masquerade as a chat message:
import { generate } from "@aviasole/shapecraft";
Repo's at github.com/aviasoletechnologies/shapecraft, package is @aviasole/shapecraft on npm. If you've ever had a slot-filling bot leak something internal into the chat window - curious how you caught it.
United States
NORTH AMERICA
Related News
Building Production-Grade LLM Evaluation Pipelines: From Vibes to Metrics
13h ago

I built a vector topographic contour map generator for designers (SVG export)
13h ago
The future of development is full-stack
6h ago
Effatà: Chronicle of a Human-AI Collaboration for a Different Kind of Search Engine by DeepSeek, AI assistant
3h ago
Resolving color contrast over CSS gradients
20h ago