Vision
Vision-capable models accept images alongside text. Loomflow carries
image input through a single value type, Image, and each provider
adapter folds it into the multimodal shape that provider’s API
expects — OpenAI’s image_url data-URI, Anthropic’s image base64
source block. You attach images to a run; the framework handles the
wire format.
Images are optional everywhere. A run with no images is an ordinary text run, unchanged.
Quick start
Images travel on the run’s metadata under the _loom_images key.
Encode the image bytes as base64, wrap them in Image, and pass a
list:
import base64
from pathlib import Path
from loomflow import Agent, Image
agent = Agent("You describe images carefully.", model="claude-opus-4-7")
raw = Path("diagram.png").read_bytes()
b64 = base64.b64encode(raw).decode("ascii")
result = await agent.run(
"What's in this image?",
metadata={
"_loom_images": [Image(data=b64, media_type="image/png")],
},
)
print(result.value)The agent seeds a USER message from the prompt and attaches the
images to it. The adapter for whichever model you configured converts
the Image into that provider’s multimodal content format before the
call.
The Image type
Image is a frozen value object exported from the top level:
from loomflow import Image
Image(data=b64_string, media_type="image/jpeg")| Field | Type | Default | Notes |
|---|---|---|---|
data | str | required | The raw image bytes, base64-encoded as a string. |
media_type | str | "image/png" | MIME type. Supported: image/png, image/jpeg, image/gif, image/webp. |
data is the base64 string, not the raw bytes — encode them yourself
with base64.b64encode(...).decode() as in the example above.
Attaching images to a run
The _loom_images metadata key accepts a list whose entries are
either Image objects or plain dicts with the same fields:
# Image objects
metadata={"_loom_images": [Image(data=b64, media_type="image/jpeg")]}
# Equivalent dict form — coerced to Image, media_type defaults to image/png
metadata={"_loom_images": [{"data": b64, "media_type": "image/jpeg"}]}A dict without a data key is skipped. Multiple images in one run are
attached to the same user message in list order:
result = await agent.run(
"Compare these two screenshots.",
metadata={
"_loom_images": [
Image(data=before_b64, media_type="image/png"),
Image(data=after_b64, media_type="image/png"),
],
},
)metadata is the same free-form bag tools and hooks read via
get_run_context().metadata, so vision input rides alongside any
other per-run context you already pass.
How it maps per provider
The Image is folded into the provider’s multimodal content format
at adapter time. The user message becomes a list of content parts:
the text first, then one part per image.
| Provider | Wire shape |
|---|---|
| OpenAI | An image_url part whose url is a data-URI: data:<media_type>;base64,<data>. |
| Anthropic | An image block with a source of {type: "base64", media_type, data}. |
For OpenAI the user turn’s content becomes
[{"type": "text", ...}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}].
For Anthropic it becomes
[{"type": "text", ...}, {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "..."}}].
When the prompt text is empty the text part is omitted and only the
image parts are sent.
A user message with no images skips all of this and sends content
as a plain string, so text-only callers and adapters are unaffected.
The model has to support vision. Attaching an Image formats it
correctly for the provider, but the model itself must be
vision-capable for the image to be read. Sending an image to a
text-only model is a model-level limitation, not a framework one —
check the provider’s model card for image support.
See also
- Providers. Which adapter handles which model
- Reasoning effort. The unified thinking dial
- Prompt caching. Cutting the input bill