# Claude stop_reason max_tokens: 83 Reports Silently Cut Off Mid-JSON

> Source: <https://dev.to/ji_ai/claude-stopreason-maxtokens-83-reports-silently-cut-off-mid-json-4jpa>
> Published: 2026-10-21 23:31:59+00:00

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.

`stop_reason == "max_tokens"`, the output is incomplete. Always check it before parsing anything.`max_tokens` costs nothing by itself. You pay for tokens generated, not for the ceiling.
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](https://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.

`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.

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.

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.

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.

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.

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.

`stop_reason` and `usage.output_tokens` for every call. I had the data for three weeks and it would have told me on day one.
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](https://preterview.com/en), an interview prep platform.*
