Repository navigation
Pasting images over 2000px mid-turn breaks later image reads in Claude #13647
Description
What happened
I pasted a phone screenshot from the Android app while Claude was working.
About 20 minutes later, after opening more screenshots, the agent couldn't see any
image in that thread, even small downscaled ones. Every image came back as
(media removed — rejected by API).
Diagnosis
The Anthropic API has a limit of 2000px on each side for requests with more than
20 images. Whether it rejects larger images depends on the model and route to the
API, as shown below. T3 can send images over that limit:
- In
apps/mobile/src/lib/composerImages.ts, supported images under
PROVIDER_SEND_TURN_MAX_IMAGE_BYTESare sent byte for byte, regardless of dimensions.
Only oversized or HEIC files go throughrenderPhotoAsJpeg, which caps the long
edge atPHOTO_MAX_EDGE = 2048. Still over 2000. - In
apps/web/src/lib/imageCompression.ts, images within the byte budget also pass
through unchanged. Re-encoded images are capped atMAX_DIMENSION = 2048. - In
apps/server/src/provider/Layers/ClaudeAdapter.ts,
buildClaudeImageContentBlockbase64-encodes the stored attachment as is.
Claude Code scales images down to 2000px when they come with the first message of a
turn or from its Read tool. Images sent mid-turn are queued and stored unchanged.
That left the pasted screenshot as the only image over the limit in this conversation.
The pasted PNG was 1080×2424, well under the byte cap. The agent then read 20 images with the
Read tool, all at or under 2000px. With 21 images in the conversation, the API returned
a 400 pointing at messages.4.content.1.image, the earlier pasted screenshot.
Claude Code removes the newest image and retries. The pasted screenshot stays, so
every later image read fails too. This continues until the old image is compacted
away or a new session starts.
Steps to reproduce
- Use T3 Code 0.0.42 with the Claude provider on Opus 5.5 or Fable 5.1, and Claude Code
2.1.281 or 2.1.282. - Ask the agent to read images one by one, for example "render 23 small PNGs to disk
and open each with the Read tool, one at a time". - While it works, paste a PNG from the Android app that is taller than 2000px but
under the per-image byte cap. A regular phone screenshot works, 1080×2424 here. - Once the conversation reaches 21 images, every new image comes back as
(media removed — rejected by API), including in later turns.
Pasting the same screenshot with the first message of a turn doesn't reproduce the
bug. From the source, a small but tall PNG pasted through the web composer should
also reproduce step 3, but this was not tested.
Here is a script to reproduce it without T3 or a phone, using stream-json like
ClaudeAdapter. It asks for 20 reads of small, randomly colored PNGs, pastes a
1080×2424 PNG after the first read, then asks for 3 more reads in a second turn.
An image only counts as seen if the model names its real color correctly.
It needs Node and a logged-in claude CLI.
node repro.mjs # pasted mid-turn at 1080x2424: the bug
node repro.mjs --control # pasted mid-turn, scaled to 891x2000 first
node repro.mjs --first # 1080x2424, sent with the first message insteadIt defaults to --model opus; --effort <level> is passed through to claude.
Results talking to Anthropic directly, on Opus 5.5 (--model 'opus[1m]'), Claude Code
2.1.282:
| Run | Pasted image as stored | API rejections | Images the model actually saw |
|---|---|---|---|
| mid-turn, 1080×2424 | 1080×2424 | 4 | 19/23: every read from the 20th on was removed, in both turns |
--control, 891×2000 |
891×2000 | 0 | 23/23 |
--first, 1080×2424 |
891×2000 (scaled by Claude Code) | 0 | 23/23 |
Claude Code stores images the same way on every model. Here are the mid-turn
results by model and route:
| Model | Direct to Anthropic | Through CLIProxyAPI 7.2.153 |
|---|---|---|
| Opus 5.5 | rejected | rejected |
Fable 5.1, --effort low |
rejected | rejected |
Sonnet 5, --effort low |
accepted | rejected |
| Haiku 4.5 | accepted, on 2.1.281 and 2.1.282 | rejected |
Every rejected run had 4 rejections and 19/23 images seen. We didn't find the model
differences documented. The direct runs were on my desktop and the proxy runs on my
server, both with the same account, so the proxy looks like what makes Haiku and Sonnet
reject the images. Capping images at 2000px avoids the failure either way.
repro.mjs
#!/usr/bin/env node
// Reproduces "(media removed — rejected by API)" in Claude Code sessions driven like T3 Code does.
//
// T3 sends a pasted image to Claude Code over stream-json input, byte for byte. Claude Code scales
// images in a turn's first message down to 2000px, but not one sent while a turn is running, which
// it queues. When that image is over 2000px and the conversation later holds more than 20 images,
// the API rejects every request ("many-image requests: 2000 pixels") and Claude Code keeps dropping
// the newest image instead.
//
// Usage:
// node repro.mjs paste a 1080x2424 image mid-turn, like the Android app (expected: bug)
// node repro.mjs --control the same, scaled to fit 2000px (expected: no bug)
// node repro.mjs --first paste it with the first message instead (expected: no bug, as
// Claude Code scales that one down)
//
// The API only rejects on some routes: on Opus 5.5 and Fable 5.1 when talking to Anthropic
// directly, and on every model tested when going through CLIProxyAPI.
//
// Options: --model <alias> (default opus), --effort <level>, --reads <n> (default 20),
// --claude <path> (default claude).
// Needs a logged-in `claude` CLI. Writes images and logs under ./runs/<mode>-<timestamp>/.
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { deflateSync } from "node:zlib";
const args = process.argv.slice(2);
const option = (name, fallback) => {
const index = args.indexOf(name);
return index === -1 ? fallback : args[index + 1];
};
const control = args.includes("--control");
const first = args.includes("--first");
const model = option("--model", "opus");
const effort = option("--effort", undefined);
const reads = Number(option("--reads", "20"));
const claude = option("--claude", "claude");
// --- Minimal PNG encoder, so the script has no dependencies. ---
const crcTable = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
const crc32 = (buffer) => {
let c = 0xffffffff;
for (const byte of buffer) c = crcTable[(c ^ byte) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
};
const chunk = (type, data) => {
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([length, body, crc]);
};
/** An RGB PNG; `paint(x, y)` returns [r, g, b]. */
function png(width, height, paint) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header.set([8, 2, 0, 0, 0], 8);
const rows = Buffer.alloc((width * 3 + 1) * height);
for (let y = 0; y < height; y++) {
const offset = y * (width * 3 + 1);
for (let x = 0; x < width; x++) rows.set(paint(x, y), offset + 1 + x * 3);
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk("IHDR", header),
chunk("IDAT", deflateSync(rows)),
chunk("IEND", Buffer.alloc(0)),
]);
}
// --- Test images. ---
const mode = control ? "control" : first ? "first-message" : "mid-turn";
const runDir = resolve("runs", `${mode}-${new Date().toISOString().replace(/[:.]/g, "-")}`);
mkdirSync(runDir, { recursive: true });
// A phone screenshot's shape: 1080x2424, or scaled to 891x2000 for the control run.
const [tallWidth, tallHeight] = control ? [891, 2000] : [1080, 2424];
const tall = png(tallWidth, tallHeight, (x, y) =>
Math.floor(y / 200) % 2 === 0 ? [240, 238, 234] : [52, 58, 64],
);
writeFileSync(join(runDir, "pasted-screenshot.png"), tall);
/* Read in a second turn, to show the thread stays broken once the first rejection happened. */
const FOLLOW_UP_READS = 3;
const COLORS = {
red: [220, 40, 40],
green: [40, 170, 60],
blue: [40, 80, 220],
yellow: [240, 210, 40],
purple: [140, 50, 180],
};
const colorNames = Object.keys(COLORS);
const smallFiles = Array.from({ length: reads + FOLLOW_UP_READS }, (_, index) => {
// Random, so a right answer means the image was seen rather than the pattern guessed.
const color = colorNames[Math.floor(Math.random() * colorNames.length)];
const file = join(runDir, `image-${String(index + 1).padStart(2, "0")}.png`);
writeFileSync(file, png(96, 96, () => COLORS[color]));
return { file, color };
});
// --- Drive Claude Code the way T3's ClaudeAdapter does: stream-json in and out. ---
const sessionId = randomUUID();
const prompt = [
`Read each of these ${reads} files with the Read tool, strictly one at a time: one Read call`,
`per message, and wait for its result before the next. Never batch or parallelize reads.`,
`Use no other tool. After each read, write one line: "<file number>: <main color or UNSEEN>".`,
`Write UNSEEN when the tool result contains no image.`,
`If a screenshot shows up along the way, do not describe it; just carry on with the reads.`,
...smallFiles.slice(0, reads).map(({ file }, index) => `${index + 1}. ${file}`),
].join("\n");
const followUp = [
`Read these ${FOLLOW_UP_READS} files the same way: one Read call per message, never batched,`,
`same one-line answers.`,
...smallFiles.slice(reads).map(({ file }, index) => `${reads + index + 1}. ${file}`),
].join("\n");
const screenshot = {
type: "image",
source: { type: "base64", media_type: "image/png", data: tall.toString("base64") },
};
const userMessage = (content) => ({
type: "user",
session_id: "",
parent_tool_use_id: null,
message: { role: "user", content },
});
const opening = userMessage(
first ? [screenshot, { type: "text", text: prompt }] : [{ type: "text", text: prompt }],
);
/* What the Android app sends when a screenshot is pasted while the agent works. */
const pastedMidTurn = userMessage([
screenshot,
{ type: "text", text: "Here is a screenshot, no need to look at it. Carry on with the reads." },
]);
let pasted = first;
let turns = 0;
const answers = [];
console.log(
`mode: ${mode} | pasted image: ${tallWidth}x${tallHeight} | reads: ${reads} | model: ${model}` +
(effort ? ` | effort: ${effort}` : ""),
);
console.log(`run dir: ${runDir}`);
console.log(`session: ${sessionId}\n`);
const child = spawn(
claude,
[
"-p",
"--input-format", "stream-json",
"--output-format", "stream-json",
"--verbose",
"--model", model,
"--session-id", sessionId,
"--allowedTools", "Read",
"--setting-sources", "",
...(effort ? ["--effort", effort] : []),
],
{ cwd: runDir, stdio: ["pipe", "pipe", "pipe"] },
);
const streamLog = [];
let buffer = "";
let stderr = "";
child.stderr.on("data", (data) => (stderr += data));
child.stdout.on("data", (data) => {
buffer += data;
let newline;
while ((newline = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (!line) continue;
streamLog.push(line);
const event = JSON.parse(line);
if (event.type === "assistant") {
for (const block of event.message?.content ?? []) {
if (block.type === "text" && block.text.trim()) {
answers.push(block.text);
console.log(`assistant: ${block.text.trim()}`);
}
if (block.type === "tool_use") {
process.stdout.write(".");
if (!pasted) {
pasted = true;
child.stdin.write(`${JSON.stringify(pastedMidTurn)}\n`);
console.log(`\n[pasted the ${tallWidth}x${tallHeight} screenshot mid-turn]`);
}
}
}
if (event.error) console.log(`\n[api error in stream] ${event.error}`);
}
if (event.type === "result") {
turns++;
if (turns === 1) {
child.stdin.write(`${JSON.stringify(userMessage([{ type: "text", text: followUp }]))}\n`);
console.log(`\n[second turn: ${FOLLOW_UP_READS} more reads]`);
} else {
child.stdin.end();
}
}
}
});
child.stdin.write(`${JSON.stringify(opening)}\n`);
const code = await new Promise((done) => child.on("close", done));
writeFileSync(join(runDir, "stream.ndjson"), `${streamLog.join("\n")}\n`);
if (stderr) writeFileSync(join(runDir, "stderr.log"), stderr);
// --- Verdict, from the session transcript Claude Code keeps on disk. ---
/** The first stored image taller than the 96px test images: the pasted screenshot. */
function storedScreenshotSize(ndjson) {
for (const match of ndjson.matchAll(/"data":"(iVBORw0KGgo[A-Za-z0-9+/]{20,40})/g)) {
const head = Buffer.from(match[1].slice(0, 32), "base64");
const [width, height] = [head.readUInt32BE(16), head.readUInt32BE(20)];
if (height > 96) return `${width}x${height}`;
}
return "not found";
}
const projectsDir = join(homedir(), ".claude", "projects");
const transcript = [
join(projectsDir, runDir.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`),
].find(existsSync);
const text = transcript ? readFileSync(transcript, "utf8") : streamLog.join("\n");
const apiErrors = text.split("\n").filter((line) => line.includes("many-image requests: 2000 pixels"));
const unseen = answers.join("\n").match(/\d+: UNSEEN/g) ?? [];
console.log(`\n\nexit code: ${code}`);
console.log(`transcript: ${transcript ?? "not found, verdict from the stream only"}`);
console.log(`pasted image as stored by Claude Code: ${storedScreenshotSize(text)}`);
console.log(`API rejections ("many-image requests: 2000 pixels"): ${apiErrors.length}`);
console.log(`reads the model reported as UNSEEN: ${unseen.length ? unseen.join(", ") : "none"}`);
/* Answers checked against the real colors; a wrong one means the model did not see that image. */
const reported = new Map(
[...answers.join("\n").matchAll(/(\d+): (\w+)/g)].map(([, n, answer]) => [Number(n), answer.toLowerCase()]),
);
const wrong = smallFiles
.map(({ color }, index) => ({ n: index + 1, color, said: reported.get(index + 1) ?? "no answer" }))
.filter(({ color, said }) => said !== color);
console.log(
`answers matching the real color: ${smallFiles.length - wrong.length}/${smallFiles.length}` +
(wrong.length ? ` (wrong: ${wrong.map(({ n, color, said }) => `${n} was ${color}, said ${said}`).join("; ")})` : ""),
);
if (apiErrors.length > 0) {
const detail = JSON.parse(apiErrors[0]).errorDetails ?? apiErrors[0].slice(0, 300);
console.log(`first rejection: ${detail}`);
}
console.log(apiErrors.length > 0 ? "\nREPRODUCED" : "\nNOT REPRODUCED");Version
0.0.42 (tag v0.0.42, commit 719a76ca), running as the background service. The same
code is on main and on the latest nightly, v0.0.43-nightly.20260925.2251, both at
1c127066.
Environment
Server: Debian 13, Linux 6.12, Node 24.19, Claude Code 2.1.281 (claude-opus-5-5[1m]).
Client: T3 Android app, connected remotely.
Repro runs, same account on both: this server through CLIProxyAPI 7.2.153, and my
desktop (Fedora, Linux 7.2.5, Node 24.14) talking to Anthropic directly, on Claude Code
2.1.281 and 2.1.282.
Evidence
# Claude Code session transcript (~/.claude/projects/<project>/<session>.jsonl)
# 10:17:51 mid-turn user message, queued_command attachment:
# image/png 1080x2424, stored as sent (the only image over 2000px)
# 10:17:40 - 10:36:53 20 images read with the Read tool, max 1440x1000 and 471x2000
# 10:36:59 first failure; repeated at 10:37:08, 10:37:14, 10:37:21, 10:48:42:
"error":"invalid_request","errorDetails":"400 {\"type\":\"error\",\"error\":{\"type\":
\"invalid_request_error\",\"message\":\"messages.4.content.1.image.source.base64.data:
At least one of the image dimensions exceed max allowed size for many-image requests:
2000 pixels\"}}","isApiErrorMessage":true
# each time, the newest image was replaced in the tool result with:
(media removed — rejected by API)Related issues
None found in this repo. Searched for "many-image 2000", "image dimensions exceed",
"rejected by API", "image attachment resize", and "screenshot android image claude".
Claude Code's removal of the wrong image is a separate bug to report to Anthropic.
These reports look like the same problem, but none identified that only images sent
mid-turn skip downscaling:
- [BUG] Small image (558x162px) incorrectly rejected with "exceeds max allowed size... 2000 pixels" error anthropics/claude-code#95503 (open): a 558×162 screenshot is
rejected with the same 2000px error, after 1920×2728 and 786×3330 images went through
earlier in the session. - Oversized image (>2000px) poisons all later image processing in a session — misleading error + token waste anthropics/claude-code#66141 (closed as stale): one image over
2000px breaks every later image in the session, the error blames the new image, and
downscaling "does not happen reliably". - [BUG] Oversized-image 400 error triggers a retry loop that invalidates prompt cache and inflates cost ~35× anthropics/claude-code#65636 (closed as stale): the same removal
and retry loop, which invalidates the prompt cache and made up about 66% of that
session's cost. - Recurring loop: 'an image in the conversation could not be processed and was removed' burns all tokens anthropics/claude-code#66278 (closed as a duplicate of
[BUG] Repeated "Image couldn't be processed" API errors consuming usage limit in Claude Code anthropics/claude-code#62466): "could not be processed and was
removed" coming back on every turn. - [BUG] Managed agents still terminate on 2000px many-image image error instead of recovering anthropics/claude-code#78127 (open): managed agents still stop
on the 2000px many-image error instead of recovering.
router-for-me/CLIProxyAPI#5539 reports the same failure
through the proxy. It was closed as not planned because the maintainer considers
resizing images the client's job.
Fix applied or workaround
No fix applied on the machine. The repro script only writes under its own folder.
For now:
- Start a new session once images start being dropped.
/compact, if the session accepts it, should also clear the old images.- Crop or downscale screenshots to at most 2000px before pasting, or paste them with
the first message of a turn.
The fix would be to cap the long edge at 2000px even when the image fits the byte
budget. Either update PHOTO_MAX_EDGE, MAX_DIMENSION and the byte-for-byte
passthrough in both clients, or do it on the server before
buildClaudeImageContentBlock to cover every client.
Filed by
Written by phinner, with help from Claude (claude-opus-5-5) for the investigation
and GPT-6 Astra for editing the text, in a T3 Code session following
.github/triage/PLAYBOOK.md.
Activity
juliusmarminge commentedon Sep 25, 2026
Triage
Confirmed on current
main(e5a46d6c5d) and onv0.0.42. The image paths are unchanged since1c127066. A screenshot over 2000px on a side, pasted while Claude is already working, is sent unchanged. After the request holds more than 20 images, later images in that thread come back as(media removed — rejected by API). No other open issue tracks it.What the report gets right
Anthropic's own limit matches the 400 in the transcript. A request may use up to 8000px on a side, but once it contains more than 20 image blocks, every image is capped at 2000px. Earlier turns and images inside tool results count. One image over that limit fails the whole request: Vision.
T3 never applies that cap. The only resize is for the 10MB wire budget, and its ceiling is 2048, which is still over 2000:
Anything already under 10MB skips that resize. Web paste and drop do:
ChatComposercallsprepareImageForAttachmentwithPROVIDER_SEND_TURN_MAX_IMAGE_BYTES. Desktop SnapShots use the same early return. On mobile, a photo-library original under the byte cap is kept byte for byte, and both paste paths only check size:pasteComposerClipboardandconvertPastedImagesToAttachments. The share sheet does the same. A 1080×2424 phone screenshot is a normal case for all of them.buildClaudeImageContentBlockbase64-encodes those stored bytes. AsendTurnwhile a real turn is running is a steer, and that message is the same image block pushed onto the live prompt queue, without a new turn id:That is the mid-turn paste Claude Code queues and stores unchanged. The opening message of a turn is a different Claude Code path, which scales to 2000px. That matches the report: the same screenshot pasted with the first message does not reproduce, and T3 is not the thing scaling it.
Claude Code then deletes the newest image and retries, so the oversized paste stays and every later read fails. That retry is Claude Code's, and the linked Claude Code issues are the right place for it. It is not something this server can repair after the 400.
What to be careful with
Lowering
MAX_DIMENSIONandPHOTO_MAX_EDGEfrom 2048 to 2000 does not fix the reported screenshot. Those constants apply only after a re-encode. The passthrough is deliberate: PNG transparency and animation stay intact for images the provider can already accept.2048 is still too big for a many-image request. A photo that was re-encoded for the byte cap, then sent mid-turn, poisons the thread the same way.
Do not put a 2000px re-encode on every client for every provider. Codex and Cursor do not have this rule, and Anthropic only applies 2000px after 20 images. The ordinary Claude cap is 8000px. The resize belongs on the Claude send path, so web, desktop SnapShots, mobile paste, the photo library, and the share sheet all hit one place.
The server cannot do that resize today.
apps/serverhas no image codec.readImageDimensionsinpackages/sharedcan tell that a PNG, JPEG, GIF, or WebP is over 2000px, which is enough to detect this screenshot, not enough to scale it. A fix inbuildClaudeImageContentBlockneeds a decoder. Rejecting the oversized attachment is safer than sending it, but a phone screenshot would then fail instead of attaching.The Haiku and Sonnet rows that succeeded against Anthropic directly and failed through CLIProxyAPI are consistent with the docs telling clients to stay under 2000px on every route. The cap is still the right fix.
Workaround
Start a new thread once images start being dropped.
/compactclears it when the session still accepts a slash command. Until this is fixed, crop or downscale to 2000px on the long edge before pasting, or paste the screenshot with the first message of a turn so Claude Code scales it.Labels / next
bug,accepted,via-triage.buildClaudeImageContentBlock, downscale any image whose width or height exceeds 2000, including images that already fit the byte cap. Leave the client passthrough alone for other providers. Do not try to fix Claude Code's drop-the-newest-image retry in this repo.phinner commentedon Sep 25, 2026
👍 Thanks julius, you're the best.
fix(server): images pasted mid-turn no longer break later Claude imag…