Claude stop_reason max_tokens: 83 Reports Silently Cut Off Mid-JSON
The support email was two lines long: "My report stops at question 6. There were 9 questions. Also there's no 'next steps' section?" I opened the report. It looked fine. Clean formatting, scores, written feedback, no er
The support email was two lines long: "My report stops at question 6. There were 9 questions. Also there's no 'next steps' section?"
I opened the report. It looked fine. Clean formatting, scores, written feedback, no error banner. It just ended early, like a book with the last three chapters torn out and the cover glued back on. The cause turned out to be one field I never read: Claude's stop_reason. When it says max_tokens, the model didn't finish. My code didn't care, and a JSON repair helper made sure nobody else would notice either. This is the autopsy of how Claude stop_reason max_tokens silently truncated 83 reports, and the four changes that took it to zero.
TL;DR
- If Claude's response has
stop_reason == "max_tokens", the output is incomplete. Always check it before parsing anything. - A JSON repair library will happily close the brackets on truncated JSON and hand you a valid, wrong object. Never repair output that hit the token cap.
- In my pipeline, 83 of 1,140 reports (7.3%) were truncated. All 83 came from sessions with 8+ questions; sessions with 7 or fewer had zero.
- Raising
max_tokenscosts nothing by itself. You pay for tokens generated, not for the ceiling. - Put the fields you can't live without (scores) first in your schema. The model generates in order, so truncation eats the tail.
What system was this?
The system is the report generator inside Preterview, an interview prep platform I built and run (full disclosure: I built it). It runs voice interviews with three interviewer styles, then sends the full transcript to Claude and asks for a structured written report: per-question feedback, rubric scores, and next steps. You can see what a session looks like at preterview.com/en.
The report call is the one place where output length actually varies a lot. A 5-question session produces a short report. A 9-question session with a chatty candidate produces a long one. I set max_tokens=1500 back when every session had 5 questions, and then I forgot that number existed.
What does stop_reason max_tokens mean in the Claude API?
stop_reason: "max_tokens" means Claude stopped because it hit the max_tokens limit you set, not because it was done. The text you get back is a prefix of what the model intended to write. For prose, that's an abrupt ending. For JSON, it's a document with unclosed brackets.
The happy value is "end_turn": the model finished on its own. There are others ("tool_use", "stop_sequence"), but for a plain "write me a JSON report" call, anything other than end_turn deserves suspicion.
Here's roughly what my original code looked like:
resp = client.messages.create(
model=MODEL,
max_tokens=1500,
system=REPORT_SYSTEM_PROMPT,
messages=[{"role": "user", "content": transcript_block}],
)
raw = resp.content[0].text
data = json.loads(repair_json(raw)) # "just in case"
report = Report.model_validate(data)
Notice what's missing. Nothing reads resp.stop_reason. And that repair_json call is the real villain.
Why didn't the truncated JSON throw an error?
Because I had wrapped the parser in a JSON repair helper (the json_repair package), added months earlier to survive the occasional trailing comma or stray markdown fence. Repair libraries are built to turn broken JSON into valid JSON. A truncated document is just broken JSON with a missing ending, so it closes the open strings, arrays and objects, and returns something parseable.
The truncated output looked like this at the tail:
{"q": 6, "feedback": "Strong STAR structure, but the result was vague. Next time quantify the"
After repair, question 6 had a feedback string ending mid-sentence, questions 7 to 9 didn't exist, and the next_steps array was gone. Then Pydantic validated it, because I'd given next_steps a default of [] and made per-question feedback a list of any length. Every layer did its job. The result was a confidently wrong report.
Out of 83 truncated responses, the repair helper "fixed" 79. Only 4 failed hard, and those were the ones that died mid-key in a way the repairer couldn't guess. So my error monitoring showed 4 failures in three weeks, which looked like noise. The real failure rate was 20 times higher.
How did I find all 83 truncated reports?
I found them by counting stop_reason in my logs, which I was storing all along and never reading. I log the raw API response metadata for every call, so a single query over three weeks gave the answer:
| Session length | Reports | stop_reason = max_tokens |
|---|---|---|
| 5-7 questions | 928 | 0 |
| 8-9 questions | 212 | 83 (39%) |
| Total | 1,140 | 83 (7.3%) |
That split is the whole story. The 1,500-token cap was sized for the product I launched, not the product I was running. When I added longer sessions, the report grew past the cap for about 4 in 10 long sessions, and nothing in the pipeline was designed to say so.
The users hit hardest were the ones who did the longest, most serious practice sessions. The people most invested in the product got the worst reports. That one stung.
How do you fix Claude max_tokens truncation in structured output?
Check stop_reason before you parse, size max_tokens for the worst case, order your schema so critical fields come first, and only repair output that finished cleanly. I shipped all four.
1. Gate on stop_reason.
resp = client.messages.create(model=MODEL, max_tokens=4096, ...)
if resp.stop_reason != "end_turn":
raise TruncatedOutput(
f"stop_reason={resp.stop_reason}, "
f"output_tokens={resp.usage.output_tokens}"
)
raw = resp.content[0].text
data = json.loads(raw)
TruncatedOutput triggers a retry path (more on that below) and fires an alert. A truncated report is now a loud failure instead of a quiet lie.
2. Raise the cap. It's free.
I'd been treating max_tokens like a budget. It isn't. You're billed for output tokens actually generated, so a cap of 4,096 on a report that needs 1,100 tokens costs exactly what a cap of 1,500 does. The cap is a safety rail against runaway output, not a cost control. I picked 4,096 because my longest observed report afterward was 2,350 tokens at p99, which leaves real headroom.
3. Reorder the schema so scores come first.
Claude writes JSON top to bottom. Whatever sits at the end of your schema is what gets cut when you run out. My original order was summary, per-question feedback, scores, next steps. The scores, the part users screenshot and compare week to week, were in the danger zone.
New order: scores, summary, next_steps, then per_question last. If something ever truncates again, the user loses detail on the final question, not their score.
4. Repair only clean finishes.
I kept repair_json, but it only runs when stop_reason == "end_turn" and json.loads fails. Then it's fixing formatting slop from a complete answer, which is what it's good at. It never touches a response that was cut off.
What happens when a report still hits the limit?
It gets retried as two smaller calls instead of one bigger one. The retry path asks for scores, summary and next steps in the first call, then per-question feedback for the second half of the questions in a separate call, and merges them. Each half fits comfortably in the cap, and the split adds about 4 seconds, which is fine for a report users read after the session ends.
So far the retry path has run 0 times. Over the next 1,300 reports after the fix, stop_reason was end_turn on every single one. The split call exists because "it can't happen" is exactly what I believed about the original bug.
What did the fix cost?
Total output tokens went up about 5%, entirely because the 8-9 question reports now finish instead of stopping early. Latency on long reports rose by roughly the time it takes to generate the missing tail. No change on short sessions. I also emailed the 61 users behind the 83 truncated reports and regenerated their reports from stored transcripts, which took one script and an awkward apology paragraph.
The lessons I'd tattoo on my CI config
- Log
stop_reasonandusage.output_tokensfor every call. I had the data for three weeks and it would have told me on day one. - Alert on any
max_tokensstop in a pipeline that expects complete output. One is a bug report, not noise. - Re-check your caps every time the input distribution changes. My cap was correct the day I wrote it.
- Treat "lenient" parsing as a liability in LLM pipelines. Every layer that forgives errors is a layer that hides them.
- Your error rate is only as honest as your strictest check. Mine said 0.35%. The truth was 7.3%.
So why were my Claude reports silently cut off?
Claude returned stop_reason: "max_tokens" on 83 of 1,140 reports because a 1,500-token cap set for 5-question sessions couldn't fit 8-9 question reports, and my code never checked stop_reason. A JSON repair helper then closed the brackets on the truncated output, so the broken reports parsed, validated and shipped without an error. The fix: reject any response where stop_reason isn't end_turn, raise max_tokens (it costs nothing until used), put critical fields first in the schema, and only repair JSON from responses that finished cleanly.
Written by the developer behind Preterview, an interview prep platform.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.