Send images to a model
Wrap an image with attach() and pass it in the context. It goes wherever its variable is rendered, in order with the text around it:
from struckdown import complete, attach, LLM, LLMCredentials
result = complete(
"Here is an answer sheet: \n"
"What SRN is written in the boxes? [[srn]]",
{"sheet": attach("scans/IMG_7553.HEIC")},
model=LLM(model_name="gpt-5.6-sol"),
credentials=LLMCredentials(api_key="...", base_url="https://litellm.example"),
)
result["srn"]
Images need Pillow: pip install 'struckdown[vision]' (which also adds HEIC support).
What attach() takes
- a path (
strorPath), image bytes, or a PillowImage; - a list of any of these, which gives an
AttachmentList.
attach() is required: a string in the context is always text, never read as a path. A Pillow Image can go in the context directly. A missing file or a file that isn’t an image raises when you call attach().
Where images go
An image variable is an ordinary context variable. Its value decides how it renders: a string as text, an attachment as that image, a list of attachments as each image in order. Indexing, loops, macros and filters are plain Jinja:
Compare these:
Front: Back:
Which photo is blurred? [[pick:blurred|1,2,3]]
The model receives one user message whose content is the text and images in that order, so it can tell “image two” from “image one”.
Images can only go in user turns. An image in a <system> block raises StruckdownAttachmentError: put it in the first user turn instead.
What is sent
Each image is re-encoded before sending:
- turned upright using its EXIF orientation;
- resized to at most
image_max_sidepx on the long side (default 2048); - saved as PNG if the original was lossless (PNG, GIF, …), otherwise as JPEG at quality 90; transparency is flattened onto white, and an animation or multi-image HEIC contributes its first frame;
- stripped of all metadata: EXIF, including GPS location, date and camera.
Models don’t read embedded metadata. If you want the model to know when or where a photo was taken, ask for it as text:
attach("IMG_1.jpg", metadata=True)
sends a line straight after the image, such as [Metadata for IMG_1.jpg: {"taken": "2026:09:18 10:02:11", "gps": {"lat": 50.37, "lon": -4.14}, "camera": "Apple iPhone 15"}].
Options
Argument to complete() | Default | Meaning |
|---|---|---|
image_max_side | 2048 | long side in px after resizing |
max_images | 50 | most images in one request, counting earlier turns |
image_detail | "auto" | detail setting passed to the provider with each image |
attach(path, detail="low") overrides the detail setting for one image.
The defaults were measured on gpt-5.6-sol via LiteLLM and Azure: the model uses no more than 2048 px (input tokens stop rising there), Azure refuses a 51st image in a request, and "auto" gives full resolution, while "high" is a cheaper, lower-resolution tier that missed fine text in testing.
Things to know
- Later slots resend earlier images. Each slot resends the conversation so far, so an image before the first slot is sent, and billed, again for every later slot. A
<checkpoint>drops earlier turns and their images; render the variable again after it if you still need the image. - A missing variable is silent. If the context has no
sheet, `` renders as nothing and the model is asked about an image it never sees. Usestrict_undefined=Truefor image templates. - Caching keys on content. The same image under another filename is a cache hit; a changed image is a miss.
- Unsupported models are caught early. Before sending, struckdown asks whether the model accepts images: the LiteLLM proxy you’re calling (its
/model/info), then LiteLLM’s public model registry, then OpenRouter’s model list. A model known not to accept images raises; an unknown one is sent anyway.STRUCKDOWN_CHECK_IMAGE_SUPPORT=0skips the lookup.
From the command line
sd chat "Describe [[description]]" --attach photo=IMG_1.jpg
sd chat "Compare [[sharper]]" --attach "photos=scans/*.jpg"
sd batch scans/*.jpg -p read_sheet.sd --as-image -o results.xlsx
--attach takes name=path; a glob gives a list. sd chat also accepts --image-max-side, --max-images and --image-detail. With --as-image, sd batch makes (and) each image itself, rather than text read from it.