Skip to content

Pasting images over 2000px mid-turn breaks later image reads in Claude #13647

Closed

Description

@phinner

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_BYTES are sent byte for byte, regardless of dimensions.
    Only oversized or HEIC files go through renderPhotoAsJpeg, which caps the long
    edge at PHOTO_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 at MAX_DIMENSION = 2048.
  • In apps/server/src/provider/Layers/ClaudeAdapter.ts,
    buildClaudeImageContentBlock base64-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

  1. 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.
  2. 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".
  3. 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.
  4. 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 instead

It 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:

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

  1. juliusmarminge commented on Sep 25, 2026

    @juliusmarminge
    Member

    Triage

    Confirmed on current main (e5a46d6c5d) and on v0.0.42. The image paths are unchanged since 1c127066. 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:

    const MAX_DIMENSION = 2048;
    const PHOTO_MAX_EDGE = 2048;

    Anything already under 10MB skips that resize. Web paste and drop do:

      if (file.size <= maxBytes) {
        return { ok: true, file, recompressed: false };
      }

    ChatComposer calls prepareImageForAttachment with PROVIDER_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: pasteComposerClipboard and convertPastedImagesToAttachments. The share sheet does the same. A 1080×2424 phone screenshot is a normal case for all of them.

    buildClaudeImageContentBlock base64-encodes those stored bytes. A sendTurn while 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:

        yield* Queue.offer(context.promptQueue, {
          type: "message",
          message:
            steeringTurnState === null
              ? { ...message, uuid: turnId as NonNullable<SDKUserMessage["uuid"]> }
              : message,
        }).pipe(Effect.mapError((cause) => toRequestError(input.threadId, "turn/start", cause)));

    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_DIMENSION and PHOTO_MAX_EDGE from 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/server has no image codec. readImageDimensions in packages/shared can 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 in buildClaudeImageContentBlock needs 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. /compact clears 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

    • Type: bug, accepted.
    • Labels: bug, accepted, via-triage.
    • Next: before 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.
  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 25, 2026
  3. phinner commented on Sep 25, 2026

    @phinner
    Author

    👍 Thanks julius, you're the best.

  4. added a commit that references this issue on Oct 14, 2026
    e7eca8f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions