I exported a fresh batch of wallpaper assets as WebP and attached them to an agent, expecting it to catch the inconsistencies in my file naming. What came back was silence. No error, no complaint — the attachment simply had not happened.
Later that week I handed over a sound effect from one of my calming apps as .opus, and the model replied that it could not handle the format. Both files open fine in any player on my machine.
Two different symptoms, but the same layer was responsible. Not the bytes in the file — the MIME type the sending side decided to stamp on it.
Hub 2.9.1, released August 20, added WebP attachments. CLI 1.1.17, released the same day, fixed .ogg, .opus, and .ogv being sent as a generic Ogg type and rejected by the model. Knowing why that happened is more useful than knowing it was fixed, because the next file that stalls will have a different extension.
Rejection happens in one of three layers
The instinct when an attachment goes missing is to re-export it in another format. That is the long way around. Decide which layer dropped it first.
| Layer | What you see | What to check first |
|---|---|---|
| 1. Client and version | Nothing happens. The attachment leaves no trace in the conversation | Your hub / IDE / CLI version and which formats that version accepts |
| 2. MIME resolution | The attachment goes through, but the model says it cannot use it | What your own machine resolves the file to (both ways, below) |
| 3. Delivery path | The turn gets heavy, or only a reference appears | Whether you attached it or an MCP server returned it |
My two failures were layer 1 and layer 2 respectively. WebP had no intake at all on hub builds before 2.9.1. Opus had an intake, but the type stamped on it was too coarse to be useful. The Windows bug where attaching images or media files made the agent fail also sat in layer 1, and was fixed in IDE 2.5.5 on August 13.
One detail worth writing down before you move on: layer 1 and layer 2 look almost identical from the outside, and the difference is whether anything at all landed in the conversation. If the agent acknowledges a file and then declines to use it, the intake worked and the type is wrong. If the conversation stays empty, the intake never happened. That single observation decides which half of this article applies to you, and it costs nothing to make.
Layer 1 goes away when you update. Layer 2 is the awkward one, because it can come back on a different machine even after you have updated. The next section shows why.
Put the two resolution methods side by side
There are two ways to decide a file's MIME type: sniff the leading bytes, or look the extension up in a table. I ran both against the same set of files.
# Resolve from content (magic bytes)
for f in sample.png sample.webp sample.ogg sample.opus sample.ogv; do
printf '%-13s %s\n' "$f" "$(file -b --mime-type "$f")"
doneOutput on Ubuntu 22.04 with file 5.41:
sample.png image/png
sample.webp image/webp
sample.ogg audio/ogg
sample.opus audio/ogg
sample.ogv audio/ogg
Now the extension table:
import mimetypes
for name in ["sample.png", "sample.webp", "sample.ogg", "sample.opus", "sample.ogv"]:
print(f"{name:<13} {mimetypes.guess_type(name)[0]}")Output on Python 3.10.12:
sample.png image/png
sample.webp None
sample.ogg audio/ogg
sample.opus audio/ogg
sample.ogv video/ogg
Line them up and two separate holes appear.
| File | From content | From extension | Consequence |
|---|---|---|---|
sample.webp | image/webp | (unresolved) | Table-based code assigns no type and sends generic bytes |
sample.opus | audio/ogg | audio/ogg | Both agree, but the fact that it is Opus is gone |
sample.ogv | audio/ogg | video/ogg | Audio and video disagree with each other |
All three Ogg-family files start with the same four bytes: 4f676753, which spells OggS. When audio and video share a container, no amount of byte sniffing will separate them. That is the plain reason a content-sniffing implementation lumped the whole family into one coarse type.
WebP fails from the opposite direction. There is no row for it in the extension table on this system, so no type gets assigned, and code that finds nothing usually falls back to a generic byte stream. From the model's side, nothing arrived that claimed to be an image, so declining to look at it is the correct behavior.
Opus collapses into generic Ogg because the machine's table wins
This was the part that held me up longest. Python's built-in table does know .opus as audio/opus. Run initialization, though, and the value changes underneath you.
import mimetypes
print(mimetypes.types_map.get(".opus")) # before init: audio/opus
mimetypes.init()
print(mimetypes.types_map.get(".opus")) # after init: audio/oggWhat overwrites it is the mapping table installed on the machine. Here is the relevant line from /etc/mime.types on mine:
audio/ogg oga ogg opus spx
opus shares a row with plain Ogg. The machine's table is read after the built-in one, so it takes precedence.
The practical consequence is that identical code produces different results on different machines. An attachment that worked on your workstation gets a different type inside a slim container or a CI runner that ships no mapping table. As an indie developer moving between a work machine and a build environment, I lost real hours to that gap — my first instinct was to blame the export settings, repeatedly.
Files arriving through MCP are typed by someone else
The third layer. Attachments are not the only way a file reaches the agent; an MCP server can hand back images or PDFs too.
On that path the type is declared by the server, not resolved by your client. Aligning your local resolution changes nothing. So the triage order has to start with a simpler question: did I attach this file, or did a tool return it?
Hub 2.9.1 changed how large binaries from connected MCP tools are handled. Instead of expanding inline, they are written to a file and referenced from the conversation. Previously an image or PDF could be expanded into the turn and break it outright. If you see a reference with no visible content, that is not a defect — that is the new storage behavior doing its job.
If the MCP server never starts in the first place, you are one step earlier than any of this. I covered that case in Find the one broken MCP entry before Antigravity starts, with 40 lines of Node.
A short check to run before you attach anything
Layer 2 can be cleared out before the file ever leaves your machine. This script compares both resolutions and surfaces disagreements and gaps.
#!/usr/bin/env python3
"""Compare content-based and extension-based MIME resolution for files you plan to attach."""
import mimetypes
import subprocess
import sys
# Extensions that content sniffing cannot separate, so agreement stays coarse
COARSE = {".opus": "audio/opus", ".ogv": "video/ogg", ".oga": "audio/ogg"}
def by_content(path):
try:
r = subprocess.run(["file", "-b", "--mime-type", path],
capture_output=True, text=True, check=True)
return r.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def main(paths):
flagged = 0
for p in paths:
ext = "." + p.rsplit(".", 1)[-1].lower() if "." in p else ""
by_ext = mimetypes.guess_type(p)[0]
by_con = by_content(p)
if by_ext is None:
print(f"[no table entry] {p}: extension resolves to nothing (content says {by_con})")
flagged += 1
elif by_con and by_ext != by_con:
print(f"[disagreement] {p}: extension {by_ext} / content {by_con}")
flagged += 1
elif ext in COARSE and by_ext != COARSE[ext]:
print(f"[too coarse] {p}: resolves to {by_ext}, should be {COARSE[ext]}")
flagged += 1
else:
print(f"[match] {p}: {by_ext}")
print(f"--- {flagged} to review out of {len(paths)} ---")
return 1 if flagged else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))Run against the same five files, it reports:
[match] sample.png: image/png
[no table entry] sample.webp: extension resolves to nothing (content says image/webp)
[match] sample.ogg: audio/ogg
[too coarse] sample.opus: resolves to audio/ogg, should be audio/opus
[disagreement] sample.ogv: extension video/ogg / content audio/ogg
--- 3 to review out of 5 ---
The COARSE map exists because agreement and correctness turned out to be different things. Both methods return audio/ogg for .opus. They agree perfectly, and the Opus identity is still gone. If agreement alone were the pass condition, that file would sail straight through.
One caveat on scope: this only settles what your own machine believes. It does not tell you what the client will ultimately put on the wire, and it cannot speak for a file that arrives through an MCP server. Treat a clean run as "layer 2 is not my problem right now" rather than as a guarantee, and you will use it correctly.
The non-zero exit code is there so the check can sit in front of automated steps. Anywhere assets get handed to a machine in bulk — App Store submission prep is my own case — stopping before the handoff beats debugging after it.
One thing to check next
Run the files you are about to attach through that script. The extensions that come back as [no table entry] are the ones that will lose their type on any environment without a mapping table. Those are the only ones that need a decision: either set the type explicitly on the client side, or re-export into a format with reliable coverage.
Updating is always available to you, of course. But new builds roll out gradually and can take several days to reach a given machine. Being able to name the layer while you wait turns that waiting into observation.
How to decide the shape of a multi-machine setup in the first place is something I went into in Choosing a Remote Control Host Machine Comes Down to Daemon Lifetime and Revocation Paths, alongside where credentials end up living. This piece is about the files that actually travel across that setup.
It was a small snag, but understanding it means I can now see the same shape coming. Thank you for reading.