I had an AI agent filling out a listing form on a web service. Every text field went in. It clicked Save Draft. All that was left was attaching three PNGs.
That part never worked.
I assumed that if it could fill the form, it could attach the files. Those turned out to be two different capabilities, gated in two different places, and the gate behaves differently depending on how you're running Claude Code. Same machine, same settings, same browser extension, different answer.
My setup: the Claude Code desktop app on Windows 11, the Claude in Chrome extension, and the computer-use desktop automation server.
The sanctioned route is closed first
The extension hands the agent a tool called file_upload. Its description tells you exactly how to use it, and what not to do:
Upload one or multiple files to a file input element on the page. Do not click on file upload buttons or file inputs — clicking opens a native file picker dialog that you cannot see or interact with. Instead, use read_page or find to locate the file input element, then use this tool with its ref to upload files directly. Only files the user has shared with this session (attachments, the session's outputs/uploads folders, or folders the user has connected) can be uploaded; other paths will be rejected. The combined size of all files in a single call must stay under 10 MB.
The first three sentences are the procedure, and the procedure worked immediately: locate the input, pass the ref and a path. The fourth sentence is where everything stopped.
Cannot upload "...": only files the user has shared with this session can be uploaded.
I tried four ways of "sharing" a file. A directory that was in the session's allowed list from the moment it started. The session's own temp folder. A folder approved through the directory-connect tool. A folder added to additionalDirectories in settings.local.json. All four came back with the same sentence.
The first one deserves a second look. That allowed directory is where the session reads and writes; I had the agent generate the test PNG there. A session cannot upload a file it wrote itself, thirty seconds earlier, in a directory it is explicitly allowed to write to.
Attaching the images to the chat doesn't help either, because on this surface a chat attachment never becomes a file. The model receives the image data, but nothing lands on disk. I scanned the temp tree, the config tree, and the app's data folder: zero new PNGs. There's a separate image-upload tool, but it requires an imageId, and chat attachments don't carry one.
None of this is a bug. The parenthetical in that description limits "shared" to exactly three things: chat attachments, the session's outputs/uploads folders, and folders the user connected.
Fine — then put the file in one of the three. Except the session directory has no outputs or uploads folder in it. So I created them, dropped a PNG in each, and tried again:
Cannot upload "...\outputs\test-out.png": only files the user has shared with this session can be uploaded.
Cannot upload "...\uploads\test-up.png": only files the user has shared with this session can be uploaded.
Matching the names changes nothing. And "folders the user has connected" can't mean the directory-connect tool, because that one is already on the rejected list. On this surface, all three of the listed escape hatches are decorative.
You can't reach the native dialog
The obvious next thought: the button opens a file picker, so drive the file picker.
That window isn't File Explorer. It's the common file dialog, owned by chrome.exe. Desktop automation decides permissions per owning process of the frontmost window, so as far as it's concerned, that dialog is Chrome.
And browsers are pinned to the "read" tier in computer-use. Screenshots yes, clicks and keystrokes no. Request access (it returns granted tier:"read"), bring Chrome to the front, try a click:
"Chrome" is granted at tier "read" — visible in screenshots only, no clicks or typing.
... never use AppleScript, System Events, shell commands, or any other method
to send clicks or keystrokes to this app.
The first half is the state. The second half enumerates the workarounds you were about to think of and then closes the list with "or any other method," which also kills route three: File Explorer is controllable at full tier, so dragging a file from Explorer onto the browser's drop zone looks viable, right up until you notice it's a way of sending input to Chrome.
Bonus trap: if you do click the button, the dialog you opened is now unclosable. Chrome is read-tier, so you can't even send it an Escape. It sits there being modal until a human walks over and dismisses it.
Same tool name, different gate
With all three routes dead I went to the docs, and found a version requirement: local file upload landed in Claude Code v2.1.211.1 Three restrictions come with it — the session must be allowed to read the file, 10 MB total per call, and files with multiple hard links are refused.
So I ran the same task from the CLI (claude --chrome) on a version past that line. The rejection message is different:
only files this session is allowed to read ... add its folder with /add-dir
The test moved from "was it shared" to "can you read it." I ran /add-dir on my Downloads folder and file_upload went straight through. Three PNGs, 731 KB total, page JavaScript rendered the previews, all three slots consumed.
Meanwhile, on the same machine, the desktop app still refuses. It sits in the same project, reads the same settings file that the CLI just used, and returns the old "only files the user has shared with this session." Typing the fix into the app gets you this:
/add-dir isn't available in this environment.
The extension is identical in both cases (Claude in Chrome 1.0.81), so the gate isn't in the extension. It's in Claude Code, implemented per surface.
Now the part that is documentation rather than measurement. The published permission rule reads like my case should work:
Permissions: Claude can upload a file only when the session is allowed to read it
Read it, upload it. My measurements say otherwise. But before calling that a spec violation — that page opens by scoping itself to browser automation "from the CLI or the VS Code extension." The desktop app is never mentioned. I had been reading the manual for the surface next to mine.
The desktop app does have its own manual.2 It even explains the /add-dir result: commands that open an interactive terminal panel behave differently in the Code tab, and the ones with no argument form answer isn't available in this environment. The message I'd been squinting at was documented behavior all along.
That manual has a browser section too — but it describes the Browser pane, the app's own built-in browser, which is not the Chrome extension. The Browser pane has no file attachment capability at all. The word "upload" does not appear on the page.
And then the same page sends you here:
When you want Claude to act as you in your logged-in sessions, use the Claude in Chrome extension instead, which shares your browser's login state.
Follow that advice and you arrive at the page that opened by telling you it's about the CLI and the VS Code extension. Two help desks, each politely pointing at the other, neither of them saying anything false.
So: not a documented condition being violated. An undocumented surface. It took reading both manuals to see that.
One footnote to save you an hour: if you're in the desktop app and you ask the agent what version you're on, it will shell out to claude --version and report your npm CLI. I got fooled by this. The shell said 2.1.215; the app was actually running the 2.1.219 it ships inside AppData\Roaming\Claude\claude-code\. There's no desktop-side version command, so the only reliable answer is the executable path of the running process.
The clipboard route works
The goal was still reachable, just not through the front door. OS clipboard plus a trusted paste event:
-
Set-Clipboard -LiteralPath <file>in PowerShell (this puts CF_HDROP on the clipboard) - Install a
pastelistener on the page - Send a real
Ctrl+Vthrough browser automation - Take the File off
clipboardData.files, put it intoinput[type=file].filesviaDataTransfer, dispatchchange
I ran this against the real form's image slot. A 307 KB PNG arrived in clipboardData.files with name, size, and MIME intact, the page's JavaScript accepted it, the preview rendered, and the save went through. No question was asked about where the file came from.
Two things cost me time here. Specifying the modifier as a separate parameter does nothing useful — you get a literal v typed into the page; the key has to be passed as the string ctrl+v. And coordinate clicks land nowhere unless you take a screenshot first, because the coordinate space is defined by the screenshot.
Working and recommendable are different things, and this route has four problems. It routes around the origin restriction on file_upload — a restriction that presumably exists to stop an agent from pushing local files onto the web without anyone looking, which is not the kind of thing you disable because it's in your way. It clobbers the clipboard, so whatever the user had copied is gone, and when an agent does that silently it reads as a malfunction. It depends on how the page is built: fine if the page watches input.files, dead if a custom uploader only listens for drop. And putting CF_HDROP on the clipboard is Windows-specific. On top of all that, it isn't a supported path, so an update could close it without notice.
Which brings me to a question. If you know the sanctioned route on the desktop app, or a way to do this without the four problems above, I'd like to hear it. Two candidates I haven't tested: running claude --chrome in the app's built-in terminal, and using the VS Code extension. Both should land on the CLI side of the gate, but I haven't confirmed either.
What half a day bought
One tool name, file_upload, two behaviors: the CLI accepts anything the session can read, the desktop app interrogates where the file came from and says no. When I design work to hand off to an agent, I'd been treating permissions as a property of the settings file. They're also a property of which binary is running.
Practically, that leaves three options for form work that involves attachments: run it from the CLI, keep a clipboard procedure on hand for the desktop app, or leave the attachment step to a human. My own runbook already said the reliable route was manual. It took half a day of building a fourth route to believe it. A known two-minute manual step beats an unknown fully-automated one, and apparently that's a lesson with a tuition fee.
What I haven't verified
- What "folders the user has connected" actually refers to. I've only established that the directory-connect tool's approval isn't it.
- Whether chat attachments count as shared on surfaces where they do materialize as files (the browser version, presumably). That's how the description reads, but I haven't tested it.
- Which Claude Code version the desktop app bundled on the day I first hit this. The number I recorded then was the npm CLI's, and the app deletes the old version folder when it updates.
- Along the way I also tried injecting via a local HTTP server, and hit a case where a
fetchfrom an HTTPS page tohttp://127.0.0.1neither succeeds nor errors — it just hangs forever. I suspect the browser's local network access check swallowed it, but I didn't confirm that.
And the big caveat: everything here is bound to a version and a surface. The CLI already passes cleanly. There's a good chance this whole post is a historical document within six months.
Verified July 2026. Environment: Windows 11, Claude Code desktop app (bundled version 2.1.219), npm CLI 2.1.215, Claude in Chrome 1.0.81, computer-use. Desktop-app re-measurement and the clipboard route were both run on 2026-07-26.
-
Use Claude Code with Chrome, "Upload files to web pages." ↩
-
Desktop application, sections "Browse external sites," "Choose between the Browser and the Chrome extension," and "What's not available in Desktop." ↩
Top comments (0)